Compare commits

..
3 Commits
Author SHA1 Message Date
JesseMarkowitzandClaude Opus 5 e482f07157 Add the interface, then rebuild the economy around the first playtest
The MVP is playable: a turn screen, a career retrospective, an optional
feedback widget, local-storage autosave with file export/import, and a build
that flattens everything into one self-contained HTML file.

A turn has two phases — the scenario and its options, then what the choice did
and, separately, what the day cost regardless. Numbers that move without the
player seeing why are most of what makes them meaningless. Locked options are
shown greyed with the requirement spelled out rather than hidden, so a player
can see the door they cannot open yet.

The interface is named entirely by the pack. `pack.display` maps a state path to
a label, a format and an order, which is why the header reads "Standing" for a
path called `reputation` and why debt disappears while it is zero.

The first playtest then found three things, all of them fair.

*"Wasn't clear what daily drain was for."* The ledger was labelling changes by
the path that moved — "Money −$18" — when the upkeep entry that caused them had
a perfectly good name. It now says "Coffee, transit, lunch". The labels were in
the data the whole time and never reached the screen.

*"Should probably pay rent weekly / get paid every other week… would be good to
have day of week and week # shown."* Packs can now declare a calendar, and the
engine derives the day name, cycle and index from the turn number before the
event is drawn — so content can require a Friday and the header can say
"Thursday · Week 2 · Day 12". Upkeep entries take `every` and `offset`, making
wages fall on alternate Fridays and rent every Monday. The turn screen carries
a diary line — "Wages tomorrow · Rent in 4 days" — computed generically from
whatever a pack schedules.

*"Always in debt — could never get ahead."* The old economy bled $25 a day
regardless of play. It is now roughly break-even at baseline, with a
`spare_shift` event that appears *because* you are broke: the way out of a hole
should be visible from inside it. Across archetypes a careful player ends around
$1,400, an unplanned one treads water, and a careless one sinks into real debt.

That retune broke the coverage test, usefully. Random play stopped reaching hard
times at all, which made the entire debt branch look dead — it was not, since
random play is not a plausible player. Coverage is now the union across five
archetypes, including one that is broke *and* well-liked, because the "ask a
friend for money" options sit in a state no single-axis strategy reaches.

Not covered by any of this: styling and dark mode, which only a human with a
browser can check. This machine has none that can render a page.

109 tests. Reasoning in docs/DECISIONS.md §14-21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-10 04:44:49 -04:00
JesseMarkowitzandClaude Opus 5 1d4ac0fdbd Add the Corporate Ladder pack, and two engine features it needed
Seventeen events across the mailroom: ordinary days, Marlene Hobb and Trevor
Pike, a misrouted envelope that can be delivered or kept, and a hard-times
branch that fires on negative money and always leaves an ungated way out.

Writing real content turned up two things the engine could not express.

Per-turn upkeep (`pack.upkeep`): the MVP spec wants recurring income and costs,
but effects only run when a player chooses something, and baking wages into
every option would be tedious and easy to get wrong. Upkeep entries carry their
own conditions, which turned out to be enough for the loan service charge to be
a three-step function that escalates with the size of the debt — no multiply
operation, no engine change. Upkeep changes are recorded separately from choice
changes so the UI can distinguish them.

Per-character starting relationships: Trevor starts below the pack default on
`likes_you` because he arrived two weeks earlier and has priced that in.
Characterisation as data rather than as an opening cutscene. Nobody starts
feared, either — `fears_you` defaults to 10, since a midpoint default would mean
every new hire arrives already intimidating.

The content suite plays 60 seeded runs and asserts every event fires and every
option is taken at least once. That caught real dead content: Trevor's favour
chain sat behind a gate no plausible player could reach, taking two events and a
hard-times escape route with it, and debt could pass 5,500 while the service
charge stayed flat, so the failure spiral flattened out instead of tightening.
Both fixed in the data — the gate softened and given another route up, the loan
capped and its charge tiered.

The validator also gained a real fix: it counted an event with a cooldown as a
stage's fallback, but a cooldown can exclude it, so a stage could still run dry.

86 tests. Reasoning in docs/DECISIONS.md §11-13, including the seeding trap that
makes a correlated chooser look exactly like a content bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-09 21:58:20 -04:00
JesseMarkowitzandClaude Opus 5 cc59b81155 Add the engine core: path-addressed state, data-driven turns
The planning docs commit to one thing above all: adding content must never
require touching the engine. This lays the foundation that makes that true.

All game state is addressable by dotted path (money, skills.negotiation,
relationships.boss.likes_you, flags.took_loan), and conditions and effects are
generic operations over those paths. The schema in the planning docs had
`requires: { skill, min }` and `effects: { money, skills, relationships }`,
both of which hardcode the state shape into the engine; the first gate wanting
a relationship threshold or a flag would have meant an engine change.

There is deliberately no "random event" category. Every turn filters the whole
event pool by stage and conditions and draws by weight, so a routine turn, a
rare interruption and the hard-times branch differ only in their data.

Also here, neither in the planning docs but both cheap and load-bearing:

- A seeded, serializable RNG. Runs replay exactly from seed plus choices, which
  makes tests deterministic and playtest reports reproducible. The generator's
  position is one uint32 living in the save.
- A load-time content validator. Undeclared paths, unknown character ids, ids
  that cannot be path segments, events that can never fire and stages with no
  fallback event are all caught on load instead of forty turns into a game.

The turn loop is a pure function, so a saved game resumes into exactly the run
it left — covered by a test that plays thirty turns, plays the same thirty with
a JSON round trip in the middle, and deep-equals the two.

72 tests, no dependencies. Reasoning recorded in docs/DECISIONS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-09 21:43:46 -04:00
46 changed files with 5426 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store
+177
View File
@@ -0,0 +1,177 @@
# The Ladder
A turn-based career simulation game that runs entirely in the browser. You
start at the bottom of an organisation and play turns — a day at a time —
making choices that move your money, your skills and your standing with the
people around you. There is no win state and no game over: a bad run means
worse options, not an ending, and you can stop whenever you like and read the
retrospective.
The engine knows nothing about offices, mailrooms or bosses. A **setting is a
content pack** — characters, events, choices, stat names and tuning, all data.
`corporateladder` is the default pack; a Wild West or lemonade-stand pack would
be new data and no new code.
## Status
Engine and the first content pack are built and tested. No UI yet — the game is
currently playable only from a test harness.
| Piece | State |
| --- | --- |
| Path-addressed state, conditions, effects | done |
| Seeded RNG, save/resume | done |
| Event selection, option gating | done |
| Turn loop, per-turn upkeep | done |
| Content validator + reachability tests | done |
| Corporate Ladder pack — 17 events, 2 characters | done |
| Turn screen, retrospective, feedback widget | done |
| Local-storage save, file export/import | done |
| Single-file build | done |
| Calendar, scheduled wages and rent | done |
| Played in a real browser | done — first playtest 2026-09-09 |
The first playtest confirmed the interface, saves and the exported log all work
in a real browser. Its findings — an unexplained daily drain, no sense of the
week, and an economy nobody could get ahead in — are what the calendar and the
retuned economy are for.
Note that the development machine has no browser that can render a page, so the
UI tests drive the real modules against a minimal fake DOM. That covers wiring,
not layout: **styling and dark mode are only ever verified by a human opening
it.**
## Playing it
```sh
npm run serve # then open http://localhost:8080
```
A server is needed during development because browsers refuse ES module imports
over `file://`. If port 8080 is taken, run `python3 -m http.server <port>`
instead.
To hand the game to someone else:
```sh
npm run build # writes dist/theladder.html
```
That is one self-contained file with the stylesheet and every module inlined.
It plays by double-clicking it — no server, no network, nothing installed.
```sh
npm test # 109 tests: engine, content, UI wiring, and the build
```
There are no runtime dependencies and no build step for development. Node is
used only to run the tests and the single-file build.
## Layout
```
src/engine/ the game engine — imports nothing from content/, or from src/ui/
paths.js dotted-path get/set over state
conditions.js generic predicates: { path, op, value }, all/any/not
effects.js generic mutations: add/set/push/remove, with clamping
state.js pack -> schema and initial state
select.js which event fires this turn, which options are available
game.js the turn loop
rng.js seeded, serializable RNG
validate.js load-time content checking
src/ui/ screens, formatting and the app controller
app.js the only module that names a content pack
screens/ the turn screen and the retrospective
format.js state paths and conditions rendered as English
src/io/ local-storage save, file export/import, the feedback log
content/ content packs (settings)
tools/build.js flattens everything into one self-contained HTML file
test/ engine tests against a fixture pack; content and UI tests
docs/ DECISIONS.md — why things are the way they are
Planning/ the original design documents
```
**The rule that keeps this honest: nothing in `src/engine/` may import from
`content/`.** The engine names no stat, no character and no setting of its own.
## How content works
A pack declares what state exists and what it starts at, the relationship stats
every character carries, the characters, and the events. Everything else falls
out of that.
```js
export default {
id: 'corporateladder',
startingStage: 'mailroom',
state: {
'money': { initial: 1200, min: -5000, max: 1000000 },
'skills.negotiation': { initial: 1, min: 0, max: 100 },
'flags.took_loan': { initial: false },
},
// Expanded per character into relationships.<id>.<stat>, so adding a
// character adds its relationship state automatically.
relationshipStats: {
likes_you: { initial: 50, min: 0, max: 100 },
fears_you: { initial: 50, min: 0, max: 100 },
wants_to_help_you: { initial: 50, min: 0, max: 100 },
},
characters: [
{ id: 'boss', name: '...', role_type: 'boss', description: '...' },
],
events: [
{
id: 'mail_run',
stage: 'mailroom',
weight: 100, // relative likelihood among eligible events
description: 'A cart of mail and two hours to move it.',
options: [
{
id: 'hustle',
label: 'Get it done fast.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 3 }],
effects: [
{ path: 'money', op: 'add', value: 40 },
{ path: 'relationships.boss.likes_you', op: 'add', value: 3 },
],
},
],
},
],
};
```
An event may also carry `once: true`, a `cooldown` in turns, and `requires`
conditions of its own. There is no separate notion of a random event: every
turn draws from the whole eligible pool by weight.
Conditions may read engine bookkeeping (`turn`, `stage`, `seen.<eventId>`,
`lastSeen.<eventId>`) as well as pack state. Effects may not write it.
A pack also declares `upkeep` — a list of `{ label, requires?, effects }`
applied at the end of a turn, after the choice. Adding `every` and `offset`
makes an entry periodic rather than daily: Corporate Ladder pays wages on
alternate Fridays (`{ every: 14, offset: 4 }`), charges rent every Monday, and
adds a loan service charge that steps up as the debt grows.
A pack may also declare a `calendar`:
```js
calendar: {
cycleLength: 7,
cycleName: 'Week',
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
},
```
The engine derives `calendar.index`, `calendar.cycle` and `calendar.name` from
the turn number before each event is drawn, so content can require a Friday and
the interface can title the turn. Declare no calendar and there is no week.
Run `validatePack(pack)` when loading in development — it catches undeclared
paths, unknown characters, unusable ids, unreachable events and stages with no
fallback event, all of which otherwise fail silently mid-playthrough.
+30
View File
@@ -0,0 +1,30 @@
// The mailroom, as staffed.
//
// `description` is pure colour — the engine never reads it. What the engine
// does read is `startingRelationship`, where a character departs from the
// pack's defaults: nobody fears a new hire, and Trevor started two weeks
// earlier and has priced that in.
export const characters = [
{
id: 'marlene',
name: 'Marlene Hobb',
title: 'Mailroom Supervisor',
role_type: 'boss',
description:
'Has worked here nineteen years. Refers to the building as "the plant." ' +
'Keeps a laminated card of the org chart, updated in pen.',
},
{
id: 'trevor',
name: 'Trevor Pike',
title: 'Mail Associate II',
role_type: 'rival',
description:
'Arrived two weeks before you and has never once let that go. ' +
'Photographs his lunch.',
startingRelationship: { likes_you: 35, wants_to_help_you: 25 },
},
];
export default characters;
@@ -0,0 +1,126 @@
// Running out of money.
//
// There is no game over here and no separate failure mode in the engine — this
// is an ordinary event whose conditions happen to be "your money is negative",
// carrying enough weight to crowd out an ordinary day. Every route out costs
// something other than money, and every one of them leaves you still playing.
//
// Both events always offer at least one ungated option, so a player with no
// skills and no friends is never stuck.
export const hardTimes = [
{
id: 'rent_bounced',
stage: 'mailroom',
weight: 5000,
requires: [{ path: 'money', op: '<', value: 0 }],
description:
'Rent came out and did not clear. The letter uses the word ' +
'"unfortunately" four times and encloses a form.',
options: [
{
id: 'take_loan',
label: 'Take the loan the letter suggests.',
// They stop lending eventually, which is when the spiral gets real:
// past this, the only ways out cost standing rather than interest.
requires: [{ path: 'debt', op: '<', value: 2000 }],
result:
'The money arrives the same afternoon, which tells you something about ' +
'the rate.',
effects: [
{ path: 'money', op: 'add', value: 500 },
{ path: 'debt', op: 'add', value: 500 },
{ path: 'flags.took_loan', op: 'set', value: true },
{ path: 'reputation', op: 'add', value: -2 },
{ path: 'notable', op: 'push', value: 'Took the loan.' },
],
},
{
id: 'ask_trevor',
label: 'Ask Trevor for a float.',
requires: [{ path: 'relationships.trevor.likes_you', op: '>=', value: 40 }],
result:
'He lends it without making a thing of it, and from then on he is a ' +
'man who lent you money.',
effects: [
{ path: 'money', op: 'add', value: 150 },
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: -8 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: -5 },
{ path: 'notable', op: 'push', value: 'Borrowed money from Trevor.' },
],
},
{
id: 'ask_marlene',
label: 'Ask Marlene for an advance.',
requires: [{ path: 'relationships.marlene.wants_to_help_you', op: '>=', value: 55 }],
result:
'She processes it herself and does not put it through the system. ' +
'Neither of you mentions it again.',
effects: [
{ path: 'money', op: 'add', value: 200 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: -10 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -3 },
],
},
{
id: 'extra_shifts',
label: 'Take every shift going.',
result:
'You do eleven days without one off. The bins stop being individual ' +
'bins after a while.',
effects: [
{ path: 'money', op: 'add', value: 120 },
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -1 },
],
},
],
},
{
id: 'debt_collector',
stage: 'mailroom',
weight: 25,
cooldown: 4,
requires: [{ path: 'debt', op: '>', value: 0 }],
description:
'A man from the lender calls the mailroom phone. Marlene answers it. She ' +
'hands it to you without expression and then, notably, without leaving.',
options: [
{
id: 'pay_chunk',
label: 'Pay a real chunk of it down.',
requires: [{ path: 'money', op: '>=', value: 350 }],
result: 'He is audibly disappointed.',
effects: [
{ path: 'money', op: 'add', value: -350 },
{ path: 'debt', op: 'add', value: -350 },
{ path: 'reputation', op: 'add', value: 1 },
],
},
{
id: 'pay_minimum',
label: 'Pay what you can.',
requires: [{ path: 'money', op: '>=', value: 60 }],
result: 'Sixty dollars, fifty of which is the loan.',
effects: [
{ path: 'money', op: 'add', value: -60 },
{ path: 'debt', op: 'add', value: -50 },
],
},
{
id: 'stall',
label: 'Tell him next month.',
result: 'He says next month. Marlene goes back to her desk.',
effects: [
{ path: 'debt', op: 'add', value: 25 },
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -4 },
],
},
],
},
];
export default hardTimes;
+6
View File
@@ -0,0 +1,6 @@
import { routine } from './routine.js';
import { people } from './people.js';
import { hardTimes } from './hard-times.js';
export const events = [...routine, ...people, ...hardTimes];
export default events;
+313
View File
@@ -0,0 +1,313 @@
// Events that turn on where you stand with someone.
//
// These are gated on relationship state rather than skills, which is the point
// of tracking three separate numbers per character: a door can open because
// somebody likes you, or because they would rather you did not make a thing of
// it. The envelope chain is the pack's first taste of leverage — held, then
// spent or put back.
export const people = [
{
id: 'marlene_late',
stage: 'mailroom',
weight: 80,
cooldown: 3,
description:
'Marlene needs someone to stay until the six o\'clock courier. She is not ' +
'asking you specifically. She is looking at you specifically.',
options: [
{
id: 'stay',
label: 'Stay.',
result: 'The courier is twenty minutes late. Marlene waits with you.',
effects: [
{ path: 'money', op: 'add', value: 45 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 5 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 4 },
],
},
{
id: 'decline',
label: 'Say you have somewhere to be.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result: 'She says "of course" in a way that files it somewhere.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -3 },
],
},
{
id: 'volunteer_trevor',
label: 'Suggest Trevor is free.',
result: 'Trevor is, as it turns out, free. He finds out the way you did.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: -6 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 4 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 1 },
],
},
],
},
{
id: 'trevor_credit',
stage: 'mailroom',
weight: 60,
cooldown: 6,
description:
'The floor manager thanks Trevor for the reorganised outbound shelf. ' +
'Trevor accepts this. You built the shelf on Tuesday.',
options: [
{
id: 'let_it_go',
label: 'Let it go.',
result: 'Trevor is warmer to you all week, which is somehow worse.',
effects: [
{ path: 'reputation', op: 'add', value: -1 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 4 },
],
},
{
id: 'correct',
label: 'Correct the record, politely.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result:
'You do it well enough that nobody is embarrassed except Trevor, who ' +
'is embarrassed thoroughly.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: 4 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -8 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 5 },
],
},
{
id: 'private',
label: 'Mention it to Trevor later, alone.',
result: 'He apologises twice, which is once more than he needed to.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -2 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 6 },
],
},
],
},
{
id: 'misrouted_envelope',
stage: 'mailroom',
weight: 35,
cooldown: 8,
requires: [{ path: 'flags.holds_the_envelope', op: '==', value: false }],
description:
'A sealed envelope, misrouted. Wrong floor, wrong name — and the name is ' +
'one you have seen on the quarterly. It is not sealed especially well.',
options: [
{
id: 'deliver',
label: 'Deliver it. Say nothing.',
result: 'It goes where it was going. You think about it on the way back.',
effects: [
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'notable', op: 'push', value: 'Delivered the misrouted envelope unopened.' },
],
},
{
id: 'hold',
label: 'Put it in your locker for now.',
result: 'For now.',
effects: [
{ path: 'flags.holds_the_envelope', op: 'set', value: true },
{ path: 'notable', op: 'push', value: 'Kept an envelope that was not addressed to you.' },
],
},
{
id: 'to_marlene',
label: 'Take it to Marlene.',
result: 'She reroutes it without looking at it, which took her years.',
effects: [
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 3 },
],
},
],
},
{
id: 'envelope_pressure',
stage: 'mailroom',
weight: 45,
cooldown: 6,
requires: [{ path: 'flags.holds_the_envelope', op: '==', value: true }],
description:
'The envelope is still in your locker. It has begun to feel heavier than paper.',
options: [
{
id: 'return_it',
label: 'Put it back into the system, late.',
result: 'Late mail is nobody\'s scandal. It clears.',
effects: [
{ path: 'flags.holds_the_envelope', op: 'set', value: false },
{ path: 'reputation', op: 'add', value: -1 },
],
},
{
id: 'let_it_be_known',
label: 'Let it be known, quietly, that you have it.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 30 }],
result:
'You never say what is in it. You never have to. The mailroom gets ' +
'noticeably more punctual.',
effects: [
{ path: 'relationships.marlene.fears_you', op: 'add', value: 10 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 10 },
{ path: 'reputation', op: 'add', value: -4 },
{ path: 'notable', op: 'push', value: 'Let it be known you had the envelope.' },
],
},
{
id: 'leave_it',
label: 'Leave it where it is.',
result: 'You check the locker twice on your way out.',
effects: [{ path: 'reputation', op: 'add', value: -1 }],
},
],
},
{
id: 'trevor_favor',
stage: 'mailroom',
weight: 55,
cooldown: 5,
requires: [
{ path: 'relationships.trevor.likes_you', op: '>=', value: 48 },
{ path: 'flags.covered_for_trevor', op: '==', value: false },
],
description:
'Trevor asks if you would cover the last hour on Thursday. He does not ' +
'say why. He does say it twice.',
options: [
{
id: 'cover',
label: 'Cover it.',
result: 'Thursday is quiet. Trevor is not there to see how quiet.',
effects: [
{ path: 'flags.covered_for_trevor', op: 'set', value: true },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 6 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 8 },
],
},
{
id: 'trade',
label: 'Cover it, and say what you want in return.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result: 'He agrees quickly, and then thinks about how quickly he agreed.',
effects: [
{ path: 'flags.covered_for_trevor', op: 'set', value: true },
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 5 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 4 },
],
},
{
id: 'refuse',
label: 'Say no.',
result: 'He says it is fine. He asks Marlene, who says no as well.',
effects: [{ path: 'relationships.trevor.likes_you', op: 'add', value: -5 }],
},
],
},
{
id: 'call_in_favor',
stage: 'mailroom',
weight: 50,
cooldown: 4,
requires: [{ path: 'flags.covered_for_trevor', op: '==', value: true }],
description:
'Thursday was a while ago now. Trevor has not brought it up, which is its ' +
'own kind of answer.',
options: [
{
id: 'call_it_in',
label: 'Ask him to take the afternoon bins.',
result: 'He takes them. The ledger is closed and both of you know it.',
effects: [
{ path: 'flags.covered_for_trevor', op: 'set', value: false },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'reputation', op: 'add', value: 1 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: -4 },
],
},
{
id: 'bank_it',
label: 'Leave it. It keeps.',
result: 'It keeps. He knows it keeps.',
effects: [{ path: 'relationships.trevor.fears_you', op: 'add', value: 2 }],
},
{
id: 'forgive',
label: 'Tell him to forget it.',
result: 'He does not forget it, but he does stop bringing you coffee wrong.',
effects: [
{ path: 'flags.covered_for_trevor', op: 'set', value: false },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 7 },
],
},
],
},
{
id: 'marlene_confides',
stage: 'mailroom',
weight: 50,
cooldown: 8,
requires: [{ path: 'relationships.marlene.likes_you', op: '>=', value: 65 }],
description:
'Marlene tells you, without preamble, that she was told nineteen years ago ' +
'that the mailroom was a two-year posting. She then asks whether the ' +
'third-floor bins have gone up.',
options: [
{
id: 'ask_about_it',
label: 'Ask her about it.',
result: 'She talks for a while. She has not told this to Trevor.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 5 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 6 },
{
path: 'notable',
op: 'push',
value: 'Marlene told you about the two-year posting.',
},
],
},
{
id: 'answer_the_bins',
label: 'Tell her the bins have gone up.',
result: 'She writes it down. The subject is closed with visible relief.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
],
},
{
id: 'ask_how_to_leave',
label: 'Ask her how to get out of the mailroom.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result:
'She gives you three names and a piece of advice she says she wishes ' +
'someone had given her, which is how you know it is good.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 4 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 5 },
],
},
],
},
];
export default people;
+436
View File
@@ -0,0 +1,436 @@
// Ordinary days in the mailroom.
//
// The first three carry no conditions, no cooldown and no `once`, which makes
// them the stage's floor: whatever else is excluded, there is always a turn to
// play. Everything else in the pack leans on that.
export const routine = [
{
id: 'first_day',
stage: 'mailroom',
weight: 99999,
once: true,
description:
'Marlene Hobb hands you a laminated card with the org chart on it. Your ' +
'name is not on the card. She says it will be, eventually, in pen.',
options: [
{
id: 'listen',
label: 'Ask her to walk you through it.',
result: 'She walks you through it. Then she walks you through it again.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
{ path: 'notable', op: 'push', value: 'Let Marlene explain the org chart. Twice.' },
],
},
{
id: 'wing_it',
label: "Say you'll pick it up as you go.",
result: 'She puts the card back in her pocket without comment.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -2 },
],
},
{
id: 'ask_trevor',
label: 'Ask Trevor where you actually sit.',
result:
'He shows you a desk, then mentions he has been here two weeks longer ' +
'than you. This will not be the last time.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: 3 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 2 },
],
},
],
},
{
id: 'mail_cart',
stage: 'mailroom',
weight: 100,
description:
"The mail cart's left front wheel has developed a shriek. Facilities has " +
'classified it as "cosmetic."',
options: [
{
id: 'push',
label: "Push it anyway. Everyone will know you're coming.",
result: 'By Thursday, two floors can time their morning by you.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'reputation', op: 'add', value: -2 },
],
},
{
id: 'requisition',
label: 'Spend the morning on a requisition form.',
result:
'The form requires the cart\'s asset number. The asset number is on the ' +
'wheel. You fill it in from underneath.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 1 },
],
},
{
id: 'swap',
label: 'Swap it with the good cart from the third floor.',
result: 'The third floor has no way of proving anything. They try anyway.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 3 },
],
},
],
},
{
id: 'sorting_backlog',
stage: 'mailroom',
weight: 100,
description:
'Two bins came in overnight and neither is labelled. Trevor has decided ' +
'this is a good day to reorganise his desk.',
options: [
{
id: 'grind',
label: 'Sort both bins yourself.',
result: 'It takes until four. Marlene notices, which is the point.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 3 },
// Trevor's bin got sorted too, and he is not too proud to notice.
{ path: 'relationships.trevor.likes_you', op: 'add', value: 2 },
],
},
{
id: 'split',
label: 'Ask Trevor to take one.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result: 'He takes one. He takes the lighter one, but he takes one.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -2 },
],
},
{
id: 'halfway',
label: 'Sort what fits before lunch and leave the rest.',
result: 'The rest is still there on Monday, and so are you.',
effects: [
{ path: 'reputation', op: 'add', value: -2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -2 },
],
},
],
},
{
id: 'franking_machine',
stage: 'mailroom',
weight: 70,
description:
'The franking machine is making a noise it has not made before. The manual ' +
'is in a drawer that is locked, and the key is a subject nobody will be ' +
'drawn on.',
options: [
{
id: 'fix',
label: 'Take the panel off and have a look.',
requires: [{ path: 'skills.logistics', op: '>=', value: 20 }],
result: 'It was a spring. It is now a spring in the right place.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 4 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 3 },
{
path: 'notable',
op: 'push',
value: 'Fixed the franking machine, which nobody had managed since 2019.',
},
],
},
{
id: 'cycle',
label: 'Turn it off and on.',
result: 'The noise stops. Nothing has been learned by anyone.',
effects: [{ path: 'skills.logistics', op: 'add', value: 1 }],
},
{
id: 'escalate',
label: 'Log it with Facilities and wait.',
result: 'Facilities classifies it as cosmetic.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 1 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -1 },
],
},
],
},
{
id: 'birthday_card',
stage: 'mailroom',
weight: 45,
cooldown: 5,
description:
'A card is going round for someone on the fourth floor. You have never met ' +
'them. The envelope already has two twenties in it and your name is next ' +
'on the list.',
options: [
{
id: 'contribute',
label: 'Put something in.',
result: 'You are now a person who puts something in.',
effects: [
{ path: 'money', op: 'add', value: -20 },
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 2 },
],
},
{
id: 'sign_only',
label: 'Sign it and pass it on.',
result: 'You write "all the best" and immediately regret the phrasing.',
effects: [{ path: 'reputation', op: 'add', value: -1 }],
},
{
id: 'pass',
label: 'Pass it on unsigned.',
result: 'Trevor checks the list. Trevor notices the list.',
effects: [
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -2 },
],
},
],
},
{
id: 'elevator',
stage: 'mailroom',
weight: 30,
cooldown: 10,
description:
'You are alone in the elevator with a man whose name is on the quarterly. ' +
'He looks at your cart. He asks how long the mail takes to get to eleven.',
options: [
{
id: 'honest',
label: 'Tell him the truth: forty minutes, on a good day.',
result: 'He says "forty" once, to himself, and gets out on nine.',
effects: [
{ path: 'reputation', op: 'add', value: 5 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
{
path: 'notable',
op: 'push',
value: 'Told an executive the truth about the eleventh floor.',
},
],
},
{
id: 'optimistic',
label: 'Tell him twenty.',
result: 'He seems pleased. Somebody, eventually, will have to make it twenty.',
effects: [
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'skills.negotiation', op: 'add', value: 1 },
],
},
{
id: 'unsure',
label: "Say you're not sure.",
result: 'The rest of the ride is four floors long.',
effects: [{ path: 'reputation', op: 'add', value: -1 }],
},
],
},
{
id: 'overtime_week',
stage: 'mailroom',
weight: 40,
cooldown: 7,
description:
'There is a weekend inventory. It pays. Marlene mentions it in the tone of ' +
'someone who has been told to mention it.',
options: [
{
id: 'take',
label: 'Take the shift.',
result: 'Two days of counting things that were already counted.',
effects: [
{ path: 'money', op: 'add', value: 180 },
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
],
},
{
id: 'negotiate',
label: 'Take it, for a bit more.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 30 }],
result:
'Marlene does not enjoy the conversation, but she does have the ' +
'authority, and you both find that out at the same time.',
effects: [
{ path: 'money', op: 'add', value: 260 },
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -1 },
{
path: 'notable',
op: 'push',
value: 'Negotiated a better rate for the weekend inventory.',
},
],
},
{
id: 'decline',
label: 'Pass.',
result: 'You have a weekend. It is the only one anyone gets.',
effects: [],
},
],
},
{
id: 'promotion_rumor',
stage: 'mailroom',
weight: 25,
cooldown: 12,
requires: [{ path: 'turn', op: '>=', value: 30 }],
description:
'There is a post going in Dispatch. Trevor has heard about it. Trevor ' +
'mentions that he has heard about it.',
options: [
{
id: 'ask_marlene',
label: 'Ask Marlene to put your name forward.',
requires: [{ path: 'relationships.marlene.wants_to_help_you', op: '>=', value: 60 }],
result: 'She says she will see. From Marlene this is close to a contract.',
effects: [
{ path: 'reputation', op: 'add', value: 5 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: -5 },
{
path: 'notable',
op: 'push',
value: 'Asked Marlene to put your name forward for Dispatch.',
},
],
},
{
id: 'ask_around',
label: 'Ask around on your own.',
result: 'You learn the post has been open for eleven months.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 2 },
],
},
{
id: 'ignore',
label: 'Stay where you are for now.',
result: 'Trevor mentions it again on Thursday.',
effects: [],
},
],
},
{
id: 'late_run',
stage: 'mailroom',
weight: 35,
cooldown: 5,
description:
'The evening courier run needs covering. It is an hour, it is nobody\'s ' +
'favourite hour, and it pays.',
options: [
{
id: 'take_run',
label: 'Take the run.',
result: 'An hour in the loading bay. The city looks better from there than it has any right to.',
effects: [
{ path: 'money', op: 'add', value: 70 },
{ path: 'skills.logistics', op: 'add', value: 1 },
],
},
{
id: 'swap_it',
label: 'Take it, and trade it to Trevor for the Friday one.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result: 'Friday pays the same and finishes earlier. Trevor works this out on Friday.',
effects: [
{ path: 'money', op: 'add', value: 70 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -3 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 2 },
],
},
{
id: 'go_home',
label: 'Go home.',
result: 'You are home by six. It is a strange feeling and it does not last.',
effects: [{ path: 'relationships.marlene.likes_you', op: 'add', value: -1 }],
},
],
},
{
// Deliberately surfaced by being broke: when the money runs low the board
// starts offering work. The way out of a hole should be visible from
// inside it, not something you had to have planned for.
id: 'spare_shift',
stage: 'mailroom',
weight: 300,
cooldown: 2,
requires: [{ path: 'money', op: '<', value: 350 }],
description:
'There is a shift going spare on the board — someone else\'s weekend, ' +
'going cheap because nobody wants it. Marlene has not taken it down.',
options: [
{
id: 'take_shift',
label: 'Take it.',
result: 'Two days of somebody else\'s work. The money is real.',
effects: [
{ path: 'money', op: 'add', value: 140 },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 1 },
],
},
{
id: 'take_two',
label: 'Take it, and ask what else is going.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result:
'There is more going than the board admits to. Marlene finds you ' +
'another two days without writing either of them down.',
effects: [
{ path: 'money', op: 'add', value: 265 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -1 },
],
},
{
id: 'leave_it',
label: 'Leave it for someone who needs it.',
result: 'Trevor takes it. Trevor mentions taking it.',
effects: [
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 2 },
],
},
],
},
];
export default routine;
+108
View File
@@ -0,0 +1,108 @@
// Corporate Ladder — the default content pack for The Ladder.
//
// A modern office, the mailroom, and two people who have been there longer
// than you. Everything the engine needs to run this setting is in this file
// and the ones it imports; the engine itself knows nothing about mailrooms.
import { characters } from './characters.js';
import { events } from './events/index.js';
export const corporateLadder = {
id: 'corporateladder',
name: 'Corporate Ladder',
version: '0.1.0',
turnUnit: 'day',
startingStage: 'mailroom',
stages: {
mailroom: { label: 'Mailroom', title: 'Mail Associate I' },
},
// Turn 1 is a Monday. Content can gate on the day — and, more importantly,
// money arrives and leaves on a rhythm the player can plan around instead of
// draining by an unexplained amount every day.
calendar: {
cycleLength: 7,
cycleName: 'Week',
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
},
// Money is tight but not hopeless. The baseline bleeds about three dollars a
// day, so a careful player creeps forward and a careless one does not — the
// pressure comes from what choices cost, not from an unexplained drain.
state: {
'money': { initial: 520, min: -5000, max: 1000000 },
'debt': { initial: 0, min: 0, max: 1000000 },
'reputation': { initial: 50, min: 0, max: 100 },
'skills.negotiation': { initial: 5, min: 0, max: 100 },
'skills.logistics': { initial: 5, min: 0, max: 100 },
'flags.took_loan': { initial: false },
'flags.holds_the_envelope': { initial: false },
'flags.covered_for_trevor': { initial: false },
// Moments the retrospective reads back. Content decides what is memorable;
// the full turn-by-turn record lives in history regardless.
'notable': { initial: [] },
},
// Nobody fears a new hire. Trevor's defaults are lower still — see
// characters.js, where he starts two weeks ahead of you and knows it.
relationshipStats: {
likes_you: { label: 'Likes you', initial: 50, min: 0, max: 100 },
fears_you: { label: 'Wary of you', initial: 10, min: 0, max: 100 },
wants_to_help_you: { label: 'Will help you', initial: 40, min: 0, max: 100 },
},
// Money moves on a schedule the player can feel: paid on alternate Fridays,
// rent out every Monday, and only small change in between. Over a fortnight
// the baseline is about forty dollars down — a drift, not a bleed.
upkeep: [
{ label: 'Coffee, transit, lunch', effects: [{ path: 'money', op: 'add', value: -18 }] },
// Turn 1 is a Monday, so offset 4 is the first Friday and every 14 makes it
// alternate Fridays. Rent lands on Mondays, starting the second week.
{ label: 'Wages', every: 14, offset: 4, effects: [{ path: 'money', op: 'add', value: 980 }] },
{ label: 'Rent', every: 7, offset: 7, effects: [{ path: 'money', op: 'add', value: -455 }] },
// 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
// engine nothing; a percentage would have needed a multiply operation.
{
label: 'Loan service charge',
every: 7,
offset: 7,
requires: [{ path: 'debt', op: '>', value: 0 }],
effects: [{ path: 'money', op: 'add', value: -70 }],
},
{
label: 'Loan service charge (arrears)',
every: 7,
offset: 7,
requires: [{ path: 'debt', op: '>=', value: 1000 }],
effects: [{ path: 'money', op: 'add', value: -40 }],
},
{
label: 'Loan service charge (collections)',
every: 7,
offset: 7,
requires: [{ path: 'debt', op: '>=', value: 2000 }],
effects: [{ path: 'money', op: 'add', value: -70 }],
},
],
// How the interface names and formats this pack's state. The UI reads only
// this map, so renaming a stat or reordering the header is content's job.
display: {
'money': { label: 'Money', format: 'money', order: 1 },
'debt': { label: 'Debt', format: 'money', order: 2, hideWhenZero: true },
'reputation': { label: 'Standing', format: 'meter', order: 3 },
'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 },
'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 },
'notable': { hidden: true },
'flags.took_loan': { label: 'a loan on the books', hidden: true },
'flags.holds_the_envelope': { label: 'the envelope', hidden: true },
'flags.covered_for_trevor': { label: "Trevor's Thursday", hidden: true },
},
characters,
events,
};
export default corporateLadder;
+263
View File
@@ -0,0 +1,263 @@
# Architecture decisions
Why the code is shaped the way it is. Append to this file as decisions are
made; the reasoning is worth more later than the conclusion.
## 1. All game state is addressable by path
Every value in a run — `money`, `skills.negotiation`,
`relationships.boss.likes_you`, `flags.took_loan` — is reachable by a dotted
path string, and conditions and effects are expressed against those paths.
The planning docs' schema had `requires: { skill, min }` and
`effects: { money, skills, relationships }`. Both hardcode the state shape into
the engine: the first gate that wants a relationship threshold, a money floor
or a flag, and the first effect that touches a fourth kind of state, means
editing engine code. Path addressing costs one `getPath`/`setPath` pair and
removes the whole class of change.
Everything else in the engine is built on this primitive: option gates, event
triggers, the soft-failure branch, and — later — promotion tiers.
## 2. Conditions and effects are data, evaluated generically
A condition is `{ path, op, value }` or a combinator (`all` / `any` / `not`).
An effect is `{ path, op, value }` with `add` / `set` / `push` / `remove`.
The engine knows the operators; it never knows what a path *means*.
Bounds live in the pack's state declaration (`min` / `max`) and are applied by
the effect layer, so clamping is content's decision, not the engine's.
## 3. There is no such thing as a "random event"
Every turn, the engine filters the entire event pool by stage and conditions,
then draws by weight. A routine turn is a high-weight event with loose
conditions; a rare interruption is a low-weight one; the hard-times branch is
an event whose conditions include a money threshold. The engine branches on
none of it, which is what the planning docs asked for in
`03-claude-code-build-prompt.md` §3 — but achieved by deleting the category
rather than by carefully not special-casing it.
Consequence: every stage needs at least one event that no exclusion rule can
remove (no `requires`, no `once`, no `cooldown`), or the pool can run dry
mid-run. The validator enforces this.
## 4. The engine owns some state; content may read it but not write it
`turn`, `stage`, `seen`, `lastSeen`, `history` and `meta` are engine
bookkeeping. Content can read them in conditions — "only after day 10", "only
if this event has never fired" — which is useful and safe. Writing them is a
validation error, so data cannot corrupt the loop.
`seen.<eventId>` is a count and `lastSeen.<eventId>` a turn number, which is
why event ids must be valid path segments.
## 5. Seeded, serializable RNG
Not in the planning docs; added because it is nearly free and pays three ways:
tests are deterministic, playtest reports are reproducible from seed plus
choice sequence, and the feedback log becomes replayable rather than merely
descriptive. mulberry32 was chosen because its entire state is one uint32, so
the generator's position round-trips through JSON with no special handling.
The seed and position live in `meta` inside the save file. Nothing about a run
exists outside its state object.
## 6. The turn loop is a pure function
`takeTurn(pack, state, optionId) -> { state, record }`. No mutable game object,
no hidden state. A saved game resumes into exactly the run it left — there is a
test that plays 30 turns straight, plays the same 30 turns with a JSON round
trip in the middle, and deep-equals the results.
## 7. Content is validated at load, not discovered at play
`validatePack` checks that every path referenced by a condition or effect is
declared, every character id resolves, ids are usable as path segments, no
event is unreachable, and every stage has a fallback. Without it, a typo in
content fails silently forty turns into a playthrough — the exact failure mode
that makes "just add data" unsafe for a non-programmer.
## 8. Distribution: ES modules in dev, one inlined file to ship
Chrome and Firefox block ES module imports and `fetch` over `file://`, so
"open the HTML file" and "modular JS with JSON content" are in direct conflict.
Resolved by serving during development (`npm run serve`) and inlining
everything into a single self-contained HTML file for distribution
(`npm run build`).
Related: content is authored as `.js` modules exporting plain objects rather
than `.json`. Same data, but comments and multiline strings are available —
which matters a great deal for narrative text — and it sidesteps `fetch`
entirely.
## 9. Tuning values live in the content pack
Relationship stats run 0–100 starting at 50, clamped; typical choice effects
are a few points, so one choice matters but several turns of consistent
behaviour are needed to move someone. Money is dollars. Turn is a day.
None of these are engine constants. They are declared in the pack manifest, so
retuning the game — or a pack that runs on weeks instead of days — touches no
code.
## 10. Naming
The product is **The Ladder**; the default content pack is **Corporate
Ladder**. The planning docs use "Corporate Ladder" as the working title for
both, which would have baked one setting into the product name, against the
explicit goal that corporate is one pack among many.
## 11. Per-turn upkeep is declared by the pack
The MVP spec wants "basic income and basic recurring costs", but effects only
run when a player chooses something. Baking wages and rent into every option
would be tedious and easy to get wrong.
`pack.upkeep` is a list of `{ label, requires?, effects }` applied at the end of
each turn, after the choice lands. It is still data — the engine reads it and
knows nothing about wages — and the conditions make it expressive enough to be
useful: Corporate Ladder's loan service charge is three conditional entries
forming a step function that escalates with the size of the debt, which needed
no multiply operation and no engine change.
Upkeep changes are recorded separately from choice changes in the turn record,
so the UI can show "you chose this" and "and then the month happened" as
different things.
## 12. Characters may start somewhere other than the pack default
`relationshipStats` sets the defaults; a character's `startingRelationship`
overrides individual stats. Trevor starts below the default on `likes_you`
because he arrived two weeks before you and has priced that in — which is
characterisation expressed as data rather than as an opening cutscene.
Nobody starts feared: `fears_you` defaults to 10 rather than 50, because a
midpoint default would mean every new hire arrives already intimidating.
## 13. Content is tested for reachability, not just validity
`validatePack` proves a pack *can* run. It does not prove any of it is ever
seen. The content suite plays 60 seeded runs and asserts that every event fires
and every option is taken at least once — dead content is otherwise invisible,
and two whole events plus a hard-times escape route were in fact unreachable on
the first pass because Trevor's gate sat above anything a player could
plausibly reach.
**A trap that cost a cycle:** the chooser's RNG must be seeded differently from
the game's. Both draw exactly one number per turn from the same generator, so a
shared seed correlates the choice index with the event draw and makes whole
option combinations structurally unreachable. It presents as a content bug and
is not one. Any headless playtest harness written later has to know this.
## 14. The interface is named by the pack, not by the UI
`pack.display` maps a state path to `{ label, format, order, hidden,
hideWhenZero }`. The header reads it to decide what to show, in what order, and
whether a value is money or a bar. Anything the pack does not describe falls
back to a name derived from the path, so content is never *required* to supply
a label just to be playable.
This is why the header says "Standing" for a path called `reputation`, and why
`debt` disappears while it is zero — both are content's decisions. A Wild West
pack renames the whole interface without touching a UI file.
## 15. A turn has two phases
Choosing, then outcome. The outcome phase shows what the choice did, as a list
of deltas, and separately what the day cost regardless — wages, rent, the loan.
Collapsing these into one screen would have made the numbers move without the
player seeing why, which is most of what makes them mean anything.
Locked options are rendered greyed with the requirement spelled out ("Needs
Negotiation 15+") rather than hidden. A player should be able to see the door
they cannot open yet; content can still opt into hiding one with
`whenLocked: 'hide'`.
The feedback widget lives in the outcome phase, because that is the moment a
player has an opinion — they have now seen both the options and the result.
## 16. Full re-render, with one deliberate exception
The root element is rebuilt from state on every change. At this size it is
instant and it removes a whole class of stale-view bug.
The exception is the feedback text fields, which update the model without
re-rendering: rebuilding the DOM under a cursor throws the player out of the
box they are typing in. Their contents are flushed to the log on a timer, when
the turn advances, and on `beforeunload`.
A related bug worth remembering: the flush originally skipped writing when the
feedback was empty, which meant *clearing* a rating did not clear it from the
log. Withdrawing feedback is itself feedback. The guard is now "has the player
touched this", not "is there content".
## 17. The single-file build is verified by running it
`tools/build.js` inlines the stylesheet and flattens the module graph into one
script. It is a deliberately small bundler that understands only the module
syntax this project uses and **throws on anything it does not recognise** — a
silent mis-bundle is far worse than a failed build.
The build is covered by a test that executes the bundled script against the
fake DOM and plays a turn through it. A build that merely produces a file
proves nothing; a broken bundle is otherwise discovered by whoever was handed
the file.
## 18. What the tests do not cover
There is no browser on this machine that can render the page — headless Firefox
cannot get a framebuffer, and no Chromium is installed. The UI tests drive the
real app modules against a minimal fake DOM (`test/fixtures/fake-dom.js`), which
covers wiring: imports resolve, handlers fire, screens render the right text,
choices reach the engine, saves and the log are written.
It does not cover layout, CSS, the dark-mode palette, file downloads or file
imports. Those need a human with a browser, and should be treated as unverified
until someone has clicked them.
## 19. The calendar is the pack's, derived by the engine
First playtest: *"wasn't clear what daily drain was for… should probably pay
rent weekly / get paid every other week… would be good to have day of week
shown and week # instead of just day #"*.
A pack may declare `calendar: { cycleLength, cycleName, unitNames }`. The
engine derives `calendar.index`, `calendar.cycle` and `calendar.name` from the
turn number and writes them into engine-owned state *before* the turn's event
is drawn — so content can require a Friday, and upkeep can charge rent on a
Monday. A pack that declares no calendar has none; the engine imposes no week
of its own, and `turnUnit` remains whatever the pack says.
## 20. Upkeep runs on a schedule
`every` and `offset` on an upkeep entry make it periodic: wages are
`{ every: 14, offset: 4 }` — alternate Fridays, given that turn 1 is a Monday —
and rent is `{ every: 7, offset: 7 }`. What was a flat $25/day of unexplained
drain is now $18 of small change, a fortnightly paycheque and a weekly rent
cheque, which is both easier to plan around and easier to feel.
Two display consequences, both of which were the real cause of the "unclear
drain" complaint. The ledger now labels a change with the upkeep entry that
caused it ("Rent −$455") rather than the path it moved ("Money −$455"). And
the turn screen carries a diary line — "Wages tomorrow · Rent in 4 days" —
computed generically from any pack's scheduled entries, so the player is told
what is coming before it happens rather than after.
## 21. Content coverage is measured across player archetypes
Playtest: *"always in debt — could never get ahead"*. The economy was retuned so
that a careful player climbs, a careless one sinks, and an unplanned one treads
water, with a `spare_shift` event that surfaces *because* you are broke — the
way out of a hole should be visible from inside it.
That retune broke the old coverage test, and usefully. Random play stopped
reaching hard times at all, which made the whole debt branch look dead. It was
not dead; random play is simply not a plausible player.
Coverage is now the union across five archetypes (`test/fixtures/strategies.js`)
— random, thrifty, spendthrift, sociable, and "desperate", which is broke *and*
well-liked. That last one exists because the "ask a friend for money" options
sit in a state no single-axis strategy ever reaches. The economy's shape is
asserted as an ordering between archetypes rather than as absolute figures, so
tuning does not churn the tests.
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Ladder</title>
<meta name="description" content="A turn-based career simulation. Start in the mailroom.">
<link rel="stylesheet" href="src/ui/styles.css">
</head>
<body>
<div id="app" class="app">
<p class="booting">Loading…</p>
</div>
<noscript>
<p class="booting">The Ladder needs JavaScript. It runs entirely in your browser — nothing is sent anywhere.</p>
</noscript>
<script type="module">
import { boot } from './src/ui/app.js';
boot(document.getElementById('app'));
</script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{
"name": "theladder",
"version": "0.0.1",
"private": true,
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
"type": "module",
"scripts": {
"test": "node --test \"test/**/*.test.js\"",
"serve": "python3 -m http.server 8080",
"build": "node tools/build.js"
}
}
+68
View File
@@ -0,0 +1,68 @@
// Generic predicates over state paths.
//
// A condition is either a comparison — { path, op, value } — or a combinator:
// { all: [...] }, { any: [...] }, { not: {...} }. Content uses these for
// option gates, event triggers and anything else that needs to ask a question
// about state. The engine never learns what the paths mean.
import { getPath } from './paths.js';
const COMPARATORS = {
'==': (a, b) => a === b,
'!=': (a, b) => a !== b,
'>': (a, b) => typeof a === 'number' && typeof b === 'number' && a > b,
'>=': (a, b) => typeof a === 'number' && typeof b === 'number' && a >= b,
'<': (a, b) => typeof a === 'number' && typeof b === 'number' && a < b,
'<=': (a, b) => typeof a === 'number' && typeof b === 'number' && a <= b,
'in': (a, b) => Array.isArray(b) && b.includes(a),
'not-in': (a, b) => Array.isArray(b) && !b.includes(a),
'has': (a, b) => Array.isArray(a) && a.includes(b),
'not-has': (a, b) => Array.isArray(a) && !a.includes(b),
};
export const OPERATORS = Object.keys(COMPARATORS);
/** True if `condition` holds against `state`. */
export function evaluate(state, condition) {
if (condition === null || typeof condition !== 'object') {
throw new TypeError(`condition must be an object, got ${JSON.stringify(condition)}`);
}
if (Array.isArray(condition)) return condition.every((c) => evaluate(state, c));
if ('all' in condition) return condition.all.every((c) => evaluate(state, c));
if ('any' in condition) return condition.any.some((c) => evaluate(state, c));
if ('not' in condition) return !evaluate(state, condition.not);
const { path, op, value } = condition;
const compare = COMPARATORS[op];
if (!compare) throw new TypeError(`unknown condition operator ${JSON.stringify(op)}`);
return compare(getPath(state, path), value);
}
/**
* Evaluate a list of conditions, returning the ones that failed. An empty or
* absent list passes. The failures are what the UI greys out an option with
* and what the feedback log records as "the player could not pick this".
*/
export function check(state, conditions) {
if (!conditions) return { passed: true, failed: [] };
const list = Array.isArray(conditions) ? conditions : [conditions];
const failed = list.filter((condition) => !evaluate(state, condition));
return { passed: failed.length === 0, failed };
}
/** Every path a condition tree reads. Used by the validator. */
export function pathsUsed(condition, out = []) {
if (condition === null || typeof condition !== 'object') return out;
if (Array.isArray(condition)) {
for (const c of condition) pathsUsed(c, out);
} else if ('all' in condition) {
for (const c of condition.all) pathsUsed(c, out);
} else if ('any' in condition) {
for (const c of condition.any) pathsUsed(c, out);
} else if ('not' in condition) {
pathsUsed(condition.not, out);
} else if (typeof condition.path === 'string') {
out.push(condition.path);
}
return out;
}
+62
View File
@@ -0,0 +1,62 @@
// Generic mutations over state paths.
//
// An effect is { path, op, value }. Applying a list returns a new state plus
// a change record — the before/after of every path touched. That record is
// what the feedback log stores, so "what did this choice actually do" is
// answerable after the fact without replaying the run.
import { getPath, setPath } from './paths.js';
const OPS = {
add: (current, value) => (typeof current === 'number' ? current : 0) + value,
set: (_current, value) => value,
push: (current, value) => [...(Array.isArray(current) ? current : []), value],
remove: (current, value) => (Array.isArray(current) ? current.filter((v) => v !== value) : []),
};
export const OPERATIONS = Object.keys(OPS);
/** Constrain a value to the bounds its schema entry declares, if any. */
function clamp(value, rule) {
if (!rule || typeof value !== 'number') return value;
let out = value;
if (typeof rule.min === 'number') out = Math.max(rule.min, out);
if (typeof rule.max === 'number') out = Math.min(rule.max, out);
return out;
}
/**
* Apply one effect. `schema` is the resolved state schema, consulted only for
* clamping — the engine still has no idea what any given path means.
*/
export function applyEffect(state, effect, schema = {}) {
const { path, op, value } = effect;
const operate = OPS[op];
if (!operate) throw new TypeError(`unknown effect operation ${JSON.stringify(op)}`);
const from = getPath(state, path);
const raw = operate(from, value);
const to = clamp(raw, schema[path]);
return {
state: setPath(state, path, to),
change: { path, op, value, from, to, clamped: raw !== to },
};
}
/** Apply a list of effects in order. Returns the new state and every change. */
export function applyEffects(state, effects, schema = {}) {
let next = state;
const changes = [];
for (const effect of effects ?? []) {
const result = applyEffect(next, effect, schema);
next = result.state;
changes.push(result.change);
}
return { state: next, changes };
}
/** Every path a list of effects writes. Used by the validator. */
export function pathsUsed(effects) {
return (effects ?? []).map((effect) => effect.path).filter((p) => typeof p === 'string');
}
+155
View File
@@ -0,0 +1,155 @@
// The turn loop.
//
// Purely functional: a turn is (pack, state, optionId) -> new state. The RNG
// is rebuilt from the seed and position carried in state, so a saved game
// resumes into exactly the run it left, and a test can replay a whole career
// with no hidden state anywhere.
import { createRng } from './rng.js';
import { applyEffects } from './effects.js';
import { check } from './conditions.js';
import { resolveSchema, createState } from './state.js';
import { selectEvent, resolveOptions, findOption } from './select.js';
import { setPath, getPath } from './paths.js';
export class ContentError extends Error {}
export class MoveError extends Error {}
/** Draw the next event and record where the generator ended up. */
function advance(pack, state) {
// The calendar is settled before the draw, so an event can require a Friday.
const dated = applyCalendar(pack, state);
const rng = createRng(dated.meta.seed, dated.meta.rngPosition ?? undefined);
const event = selectEvent(pack, dated, rng);
if (!event) {
throw new ContentError(
`no event is eligible at stage "${dated.stage}" on turn ${dated.turn}; ` +
'every stage needs at least one event with no conditions',
);
}
let next = setPath(dated, 'meta.currentEventId', event.id);
next = setPath(next, 'meta.rngPosition', rng.getPosition());
return next;
}
/**
* Derive the calendar from the turn number.
*
* A pack that declares `calendar` gets `calendar.index` (position in the
* cycle), `calendar.cycle` (which cycle, 1-based) and `calendar.name` written
* into engine-owned state before the turn's event is chosen — so content can
* gate on the day of the week, and upkeep can charge rent on one of them. A
* pack that declares no calendar simply has none; the engine imposes no week.
*/
function applyCalendar(pack, state) {
const calendar = pack.calendar;
if (!calendar?.cycleLength) return state;
const elapsed = state.turn - 1;
const index = ((elapsed % calendar.cycleLength) + calendar.cycleLength) % calendar.cycleLength;
let next = setPath(state, 'calendar.index', index);
next = setPath(next, 'calendar.cycle', Math.floor(elapsed / calendar.cycleLength) + 1);
return setPath(next, 'calendar.name', calendar.unitNames?.[index] ?? null);
}
/**
* Whether a scheduled upkeep entry falls due this turn. `every` is a period in
* turns and `offset` the first turn it lands on (zero-based), so wages every
* fortnight starting on the seventh day are `{ every: 14, offset: 6 }`. An
* entry with no `every` runs every turn.
*/
function isDue(entry, state) {
if (!entry.every) return true;
const elapsed = state.turn - 1 - (entry.offset ?? 0);
return elapsed >= 0 && elapsed % entry.every === 0;
}
/**
* Apply the pack's per-turn upkeep — wages in, rent out, whatever a loan
* costs to carry. Each entry may carry its own conditions, evaluated after the
* player's choice has landed, so upkeep is content's business and not a
* special case in the loop.
*/
function applyUpkeep(pack, state, schema) {
let next = state;
const changes = [];
for (const entry of pack.upkeep ?? []) {
if (!isDue(entry, next)) continue;
if (!check(next, entry.requires).passed) continue;
const applied = applyEffects(next, entry.effects, schema);
next = applied.state;
changes.push(...applied.changes.map((change) => ({ ...change, label: entry.label ?? null })));
}
return { state: next, changes };
}
/** Begin a run. `seed` is any string or number; it is stored in the save. */
export function startGame(pack, { seed = Date.now(), schema = resolveSchema(pack), now = null } = {}) {
let state = createState(pack, { seed, schema });
state = setPath(state, 'meta.startedAt', now);
return advance(pack, state);
}
/** The event the player is looking at. */
export function currentEvent(pack, state) {
const id = getPath(state, 'meta.currentEventId');
const event = (pack.events ?? []).find((e) => e.id === id);
if (!event) throw new ContentError(`current event "${id}" is not in pack "${pack.id}"`);
return event;
}
/** The current event together with each option's availability. */
export function currentTurn(pack, state) {
const event = currentEvent(pack, state);
return { event, options: resolveOptions(state, event) };
}
/**
* Play one option. Returns the new state and a record of what happened —
* the record is what the feedback log stores, and it deliberately includes the
* options that were locked, not just the one that was taken.
*/
export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack), now = null } = {}) {
const event = currentEvent(pack, state);
const option = findOption(event, optionId);
if (!option) throw new MoveError(`event "${event.id}" has no option "${optionId}"`);
const resolved = resolveOptions(state, event);
const chosen = resolved.find((r) => r.option.id === optionId);
if (!chosen.available) {
throw new MoveError(`option "${optionId}" is locked: ${JSON.stringify(chosen.failed)}`);
}
const applied = applyEffects(state, option.effects, schema);
let next = applied.state;
const seenCount = getPath(next, `seen.${event.id}`) ?? 0;
next = setPath(next, `seen.${event.id}`, seenCount + 1);
next = setPath(next, `lastSeen.${event.id}`, next.turn);
const upkeep = applyUpkeep(pack, next, schema);
next = upkeep.state;
const record = {
turn: next.turn,
stage: next.stage,
eventId: event.id,
optionId: option.id,
optionLabel: option.label,
changes: applied.changes,
upkeep: upkeep.changes,
offered: resolved.map((r) => ({
id: r.option.id,
label: r.option.label,
available: r.available,
failed: r.failed,
})),
at: now,
};
next = setPath(next, 'history', [...next.history, record]);
next = setPath(next, 'turn', next.turn + 1);
next = advance(pack, next);
return { state: next, record };
}
+9
View File
@@ -0,0 +1,9 @@
// The engine's public surface. Nothing in src/engine may import from content/.
export { getPath, setPath, hasPath, parsePath, listPaths } from './paths.js';
export { createRng } from './rng.js';
export { evaluate, check, OPERATORS } from './conditions.js';
export { applyEffect, applyEffects, OPERATIONS } from './effects.js';
export { createState, resolveSchema, indexCharacters, RESERVED_ROOTS } from './state.js';
export { eligibleEvents, selectEvent, resolveOptions, findOption } from './select.js';
export { startGame, currentEvent, currentTurn, takeTurn, ContentError, MoveError } from './game.js';
export { validatePack, assertValidPack } from './validate.js';
+83
View File
@@ -0,0 +1,83 @@
// Path addressing for game state.
//
// Every piece of game state is reachable by a dotted path string: `money`,
// `skills.negotiation`, `relationships.boss.likes_you`, `flags.took_loan`.
// Conditions, effects, gates and event triggers are all expressed against
// these paths, which is what lets content reach any part of state without the
// engine knowing what that part means.
const SEGMENT = /^[A-Za-z0-9_]+$/;
const FORBIDDEN = new Set(['__proto__', 'prototype', 'constructor']);
/** Split a path into segments, throwing on anything malformed or unsafe. */
export function parsePath(path) {
if (typeof path !== 'string' || path.length === 0) {
throw new TypeError(`path must be a non-empty string, got ${JSON.stringify(path)}`);
}
const segments = path.split('.');
for (const segment of segments) {
if (!SEGMENT.test(segment)) {
throw new TypeError(`invalid path segment ${JSON.stringify(segment)} in "${path}"`);
}
// Content is data, and data can be authored by anyone; never let a path
// walk into the prototype chain.
if (FORBIDDEN.has(segment)) {
throw new TypeError(`forbidden path segment ${JSON.stringify(segment)} in "${path}"`);
}
}
return segments;
}
/** Read a path. Returns undefined if any link in the chain is missing. */
export function getPath(state, path) {
let cursor = state;
for (const segment of parsePath(path)) {
if (cursor === null || typeof cursor !== 'object') return undefined;
if (!Object.prototype.hasOwnProperty.call(cursor, segment)) return undefined;
cursor = cursor[segment];
}
return cursor;
}
/** True if the path exists (distinct from resolving to undefined). */
export function hasPath(state, path) {
let cursor = state;
for (const segment of parsePath(path)) {
if (cursor === null || typeof cursor !== 'object') return false;
if (!Object.prototype.hasOwnProperty.call(cursor, segment)) return false;
cursor = cursor[segment];
}
return true;
}
/**
* Write a path, returning a new state. Ancestors along the path are copied;
* everything else is shared, so this is cheap to call in a loop.
*/
export function setPath(state, path, value) {
const segments = parsePath(path);
const root = { ...state };
let cursor = root;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
const child = cursor[segment];
cursor[segment] = (child !== null && typeof child === 'object') ? { ...child } : {};
cursor = cursor[segment];
}
cursor[segments[segments.length - 1]] = value;
return root;
}
/** Every leaf path in an object, in depth-first order. Used by the validator. */
export function listPaths(object, prefix = '') {
const out = [];
for (const [key, value] of Object.entries(object)) {
const path = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
out.push(...listPaths(value, path));
} else {
out.push(path);
}
}
return out;
}
+64
View File
@@ -0,0 +1,64 @@
// Seeded, serializable random number generator.
//
// Every draw the game makes comes from here, so a run is fully reproducible
// from its seed plus the player's choice sequence. That makes tests
// deterministic and playtest reports replayable: the seed and the generator's
// position both live in the save file and the feedback log.
/** Hash an arbitrary string seed down to a uint32. */
function hashSeed(seed) {
let h = 1779033703 ^ String(seed).length;
for (let i = 0; i < String(seed).length; i++) {
h = Math.imul(h ^ String(seed).charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
return (h ^ (h >>> 16)) >>> 0;
}
/**
* mulberry32: one uint32 of state, which is the whole point — the generator's
* position round-trips through JSON with no special handling.
*/
export function createRng(seed, position) {
let state = position === undefined ? hashSeed(seed) : position >>> 0;
const next = () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
seed,
/** Float in [0, 1). */
next,
/** Integer in [0, n). */
int: (n) => Math.floor(next() * n),
/** Uniform pick from a non-empty array. */
pick(items) {
if (!items.length) throw new RangeError('cannot pick from an empty array');
return items[Math.floor(next() * items.length)];
},
/**
* Weighted pick. `weightOf` must return a non-negative number; entries
* weighing zero are never drawn. Returns undefined if nothing has weight.
*/
weighted(items, weightOf) {
let total = 0;
for (const item of items) total += Math.max(0, weightOf(item));
if (total <= 0) return undefined;
let roll = next() * total;
for (const item of items) {
roll -= Math.max(0, weightOf(item));
if (roll < 0) return item;
}
return items[items.length - 1];
},
/** Current position, for saving mid-run. */
getPosition: () => state,
};
}
+77
View File
@@ -0,0 +1,77 @@
// Choosing what happens this turn.
//
// There is deliberately no such thing as "a scripted turn" versus "a random
// event" in here. Every turn, the whole event pool is filtered by stage and
// conditions, then drawn from by weight. A routine turn is an event with a
// high weight and loose conditions; a rare interruption is one with a low
// weight and tight conditions; the "hard times" branch is one whose
// conditions include a money threshold. The engine branches on none of it.
import { check } from './conditions.js';
import { getPath } from './paths.js';
/** Events that could fire right now, with the reason excluded ones could not. */
export function eligibleEvents(pack, state) {
const eligible = [];
const excluded = [];
for (const event of pack.events ?? []) {
if (event.stage !== undefined && event.stage !== state.stage) {
excluded.push({ event, reason: 'stage' });
continue;
}
const seenCount = getPath(state, `seen.${event.id}`) ?? 0;
if (event.once && seenCount > 0) {
excluded.push({ event, reason: 'once' });
continue;
}
if (event.cooldown) {
const lastTurn = getPath(state, `lastSeen.${event.id}`);
if (lastTurn !== undefined && state.turn - lastTurn < event.cooldown) {
excluded.push({ event, reason: 'cooldown' });
continue;
}
}
const gate = check(state, event.requires);
if (!gate.passed) {
excluded.push({ event, reason: 'requires', failed: gate.failed });
continue;
}
eligible.push(event);
}
return { eligible, excluded };
}
/**
* Draw this turn's event. Returns undefined when nothing is eligible, which is
* a content bug rather than a game state — the validator checks that at least
* one unconditional event exists per stage so this cannot happen in practice.
*/
export function selectEvent(pack, state, rng) {
const { eligible } = eligibleEvents(pack, state);
return rng.weighted(eligible, (event) => event.weight ?? 1);
}
/**
* Every option on an event, in authored order, marked available or locked and
* carrying the conditions that locked it. The UI greys locked options out; the
* feedback log records them, because what a player *could not* choose is the
* strongest signal that the choice set is too narrow.
*/
export function resolveOptions(state, event) {
return (event.options ?? []).map((option) => {
const gate = check(state, option.requires);
return {
option,
available: gate.passed,
failed: gate.failed,
hidden: !gate.passed && option.whenLocked === 'hide',
};
});
}
/** Look up one option on an event by id. */
export function findOption(event, optionId) {
return (event.options ?? []).find((option) => option.id === optionId);
}
+67
View File
@@ -0,0 +1,67 @@
// Turning a content pack into a running game state.
//
// The pack declares what state exists and what it starts at; 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.
import { setPath } from './paths.js';
/**
* Paths the engine owns. Content may *read* these in conditions — "only after
* day 10", "only if this event hasn't fired" — but may not declare or write
* them, which is what keeps engine bookkeeping from being corrupted by data.
*/
export const RESERVED_ROOTS =
new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta', 'calendar']);
/**
* Flatten a pack's state declarations into one path -> rule map, expanding the
* per-character relationship stats. `relationships.<characterId>.<stat>` is
* generated from `relationshipStats` x `characters`, so adding a character
* adds its relationship state automatically and naming a stat is the pack's
* business, never the engine's.
*/
export function resolveSchema(pack) {
const schema = {};
for (const [path, rule] of Object.entries(pack.state ?? {})) {
schema[path] = { ...rule };
}
for (const character of pack.characters ?? []) {
for (const [stat, rule] of Object.entries(pack.relationshipStats ?? {})) {
// A character may start somewhere other than the pack default — a rival
// who already resents you, a boss who has not formed an opinion.
const override = character.startingRelationship?.[stat];
schema[`relationships.${character.id}.${stat}`] =
override === undefined ? { ...rule } : { ...rule, initial: override };
}
}
return schema;
}
/** A fresh game state for `pack`, seeded for reproducibility. */
export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
let state = {
meta: {
packId: pack.id,
packVersion: pack.version ?? null,
seed: String(seed),
rngPosition: null,
startedAt: null,
},
turn: 1,
stage: pack.startingStage,
seen: {},
lastSeen: {},
calendar: {},
history: [],
};
for (const [path, rule] of Object.entries(schema)) {
state = setPath(state, path, structuredClone(rule.initial));
}
return state;
}
/** The characters a pack defines, keyed by id. */
export function indexCharacters(pack) {
return Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c]));
}
+218
View File
@@ -0,0 +1,218 @@
// Load-time validation of a content pack.
//
// This is what makes "adding content is just adding data" safe. Without it, a
// typo in a path or a character id fails silently forty turns into a
// playthrough. Run it whenever a pack is loaded in development, and in tests.
import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.js';
import { OPERATORS, pathsUsed as conditionPaths } from './conditions.js';
import { OPERATIONS, pathsUsed as effectPaths } from './effects.js';
import { parsePath } from './paths.js';
const ID = /^[A-Za-z0-9_]+$/;
/**
* Check a pack. Returns { errors, warnings, schema }; errors mean the pack
* cannot be played, warnings mean it probably does not do what was intended.
*/
export function validatePack(pack) {
const errors = [];
const warnings = [];
const schema = resolveSchema(pack);
const characters = indexCharacters(pack);
const stateRoots = new Set(Object.keys(schema).map((p) => p.split('.')[0]));
const fail = (where, message) => errors.push(`${where}: ${message}`);
const warn = (where, message) => warnings.push(`${where}: ${message}`);
if (!pack.id || !ID.test(pack.id)) fail('pack', `id ${JSON.stringify(pack.id)} must match ${ID}`);
if (!pack.startingStage) fail('pack', 'startingStage is required');
// Content may read engine bookkeeping but must never declare or write it.
for (const path of Object.keys(schema)) {
try {
const root = parsePath(path)[0];
if (RESERVED_ROOTS.has(root)) {
fail('state', `"${path}" declares reserved root "${root}", which the engine owns`);
}
} catch (error) {
fail('state', `"${path}" is not a usable path: ${error.message}`);
}
}
const seenCharacters = new Set();
for (const character of pack.characters ?? []) {
const where = `character "${character.id}"`;
if (!character.id || !ID.test(character.id)) fail(where, `id must match ${ID}`);
if (seenCharacters.has(character.id)) fail(where, 'duplicate id');
seenCharacters.add(character.id);
if (!character.name) fail(where, 'name is required');
if (!character.role_type) warn(where, 'no role_type tag; content cannot filter on it');
}
const readable = (path) => {
let root;
try {
root = parsePath(path)[0];
} catch (error) {
return error.message;
}
if (RESERVED_ROOTS.has(root)) return null;
if (path in schema) return null;
if (stateRoots.has(root)) return `"${path}" is not declared in pack.state or relationshipStats`;
return `"${path}" is not declared anywhere in this pack`;
};
const writable = (path) => {
let root;
try {
root = parsePath(path)[0];
} catch (error) {
return error.message;
}
if (RESERVED_ROOTS.has(root)) return `"${path}" writes engine-owned state`;
if (!(path in schema)) return `"${path}" is not declared in pack.state or relationshipStats`;
return null;
};
const checkConditions = (where, conditions) => {
if (!conditions) return;
const list = Array.isArray(conditions) ? conditions : [conditions];
for (const path of list.flatMap((c) => conditionPaths(c))) {
const problem = readable(path);
if (problem) fail(where, `condition reads ${problem}`);
}
const walk = (condition) => {
if (!condition || typeof condition !== 'object') return;
if (Array.isArray(condition)) return condition.forEach(walk);
if ('all' in condition) return condition.all.forEach(walk);
if ('any' in condition) return condition.any.forEach(walk);
if ('not' in condition) return walk(condition.not);
if (!OPERATORS.includes(condition.op)) {
fail(where, `unknown condition operator ${JSON.stringify(condition.op)}`);
}
};
list.forEach(walk);
};
if (pack.calendar) {
const { cycleLength, unitNames } = pack.calendar;
if (!Number.isInteger(cycleLength) || cycleLength < 1) {
fail('calendar', `cycleLength ${cycleLength} must be a positive whole number`);
}
if (unitNames && unitNames.length !== cycleLength) {
fail('calendar', `unitNames has ${unitNames.length} entries but cycleLength is ${cycleLength}`);
}
}
(pack.upkeep ?? []).forEach((entry, index) => {
const where = `upkeep[${index}]${entry.label ? ` "${entry.label}"` : ''}`;
if (entry.every !== undefined && (!Number.isInteger(entry.every) || entry.every < 1)) {
fail(where, `every ${entry.every} must be a positive whole number of turns`);
}
if (entry.offset !== undefined && (!Number.isInteger(entry.offset) || entry.offset < 0)) {
fail(where, `offset ${entry.offset} must be zero or a positive whole number`);
}
if (entry.offset !== undefined && entry.every === undefined) {
warn(where, 'offset has no effect without every');
}
checkConditions(where, entry.requires);
if (!entry.effects?.length) fail(where, 'has no effects');
for (const effect of entry.effects ?? []) {
if (!OPERATIONS.includes(effect.op)) {
fail(where, `unknown effect operation ${JSON.stringify(effect.op)}`);
}
}
for (const path of effectPaths(entry.effects)) {
const problem = writable(path);
if (problem) fail(where, `effect writes ${problem}`);
}
});
for (const character of pack.characters ?? []) {
for (const stat of Object.keys(character.startingRelationship ?? {})) {
if (!(stat in (pack.relationshipStats ?? {}))) {
fail(`character "${character.id}"`, `startingRelationship names unknown stat "${stat}"`);
}
}
}
const stages = new Set();
const unconditional = new Set();
const seenEvents = new Set();
for (const event of pack.events ?? []) {
const where = `event "${event.id}"`;
if (!event.id || !ID.test(event.id)) fail(where, `id must match ${ID} (it is used as a state path)`);
if (seenEvents.has(event.id)) fail(where, 'duplicate id');
seenEvents.add(event.id);
if (!event.description) warn(where, 'no description; the player will see an empty scenario');
if (event.stage !== undefined) stages.add(event.stage);
if (event.weight !== undefined && !(event.weight > 0)) {
fail(where, `weight ${event.weight} must be greater than zero, or the event can never fire`);
}
checkConditions(where, event.requires);
// A fallback must be one that *no* exclusion rule can remove: requires,
// once and cooldown are exactly the three reasons eligibleEvents drops an
// event, so a floor event may carry none of them.
if (!event.requires && !event.once && !event.cooldown && event.stage !== undefined) {
unconditional.add(event.stage);
}
const options = event.options ?? [];
if (options.length === 0) fail(where, 'has no options; the turn would be unplayable');
const seenOptions = new Set();
for (const option of options) {
const optionWhere = `${where} option "${option.id}"`;
if (!option.id || !ID.test(option.id)) fail(optionWhere, `id must match ${ID}`);
if (seenOptions.has(option.id)) fail(optionWhere, 'duplicate id');
seenOptions.add(option.id);
if (!option.label) fail(optionWhere, 'label is required');
checkConditions(optionWhere, option.requires);
for (const effect of option.effects ?? []) {
if (!OPERATIONS.includes(effect.op)) {
fail(optionWhere, `unknown effect operation ${JSON.stringify(effect.op)}`);
}
if (['add'].includes(effect.op) && typeof effect.value !== 'number') {
fail(optionWhere, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
}
}
for (const path of effectPaths(option.effects)) {
const problem = writable(path);
if (problem) fail(optionWhere, `effect writes ${problem}`);
}
}
}
// Every reachable stage needs a floor: one event with no conditions, or the
// pool can empty out mid-run and the turn loop has nothing to show.
if (pack.startingStage && !stages.has(pack.startingStage)) {
fail('pack', `startingStage "${pack.startingStage}" has no events`);
}
for (const stage of stages) {
if (!unconditional.has(stage)) {
fail('events', `stage "${stage}" has no unconditional event; the turn pool can run dry`);
}
}
for (const [path, rule] of Object.entries(schema)) {
if (rule.initial === undefined) fail('state', `"${path}" has no initial value`);
if (typeof rule.initial === 'number' && typeof rule.min === 'number' && rule.initial < rule.min) {
warn('state', `"${path}" starts below its own minimum`);
}
}
if (Object.keys(characters).length === 0) warn('pack', 'no characters defined');
return { errors, warnings, schema, valid: errors.length === 0 };
}
/** Throw if the pack is unplayable. Call this at load time in development. */
export function assertValidPack(pack) {
const result = validatePack(pack);
if (!result.valid) {
throw new Error(`content pack "${pack.id}" is invalid:\n ${result.errors.join('\n ')}`);
}
return result;
}
+30
View File
@@ -0,0 +1,30 @@
// Handing the player a file.
//
// Object URLs are revoked on the next frame rather than immediately: Safari
// has historically cancelled the download if the URL goes away too soon.
export function downloadText(filename, text, type = 'application/json') {
const url = URL.createObjectURL(new Blob([text], { type }));
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
requestAnimationFrame(() => URL.revokeObjectURL(url));
}
/** Read a File the player picked, as text. */
export function readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('Could not read that file.'));
reader.readAsText(file);
});
}
/** A timestamp suffix that sorts, for filenames. */
export function stamp(date = new Date()) {
return date.toISOString().slice(0, 19).replace(/[:T]/g, '-');
}
+103
View File
@@ -0,0 +1,103 @@
// The feedback and instrumentation log.
//
// Deliberately separate from the save: this is research data for the designer,
// not part of anyone's career. It is exported on its own and never merged into
// a save file.
//
// Each entry records more than the spec's minimum, for two reasons. The full
// before/after diff makes "what did that choice actually do" answerable months
// later without replaying anything. The list of options that were *offered but
// locked* is the strongest available signal that a choice set is too narrow —
// players rarely type into a comment box, but the gates they keep bouncing off
// are recorded whether they say anything or not.
const KEY = 'theladder.feedback.v1';
const FORMAT = 1;
function safely(action, fallback = null) {
try {
return action();
} catch {
return fallback;
}
}
function readRaw() {
return safely(() => {
const raw = localStorage.getItem(KEY);
return raw ? JSON.parse(raw) : [];
}, []) ?? [];
}
function writeRaw(entries) {
return safely(() => {
localStorage.setItem(KEY, JSON.stringify(entries));
return true;
}, false);
}
/**
* Record a turn. `record` is what takeTurn returned; `state` is the state it
* produced, carried for the seed and the running totals at that moment.
*/
export function logTurn(record, state, pack) {
const entries = readRaw();
entries.push({
logged_at: new Date().toISOString(),
pack_id: pack.id,
pack_version: pack.version ?? null,
seed: state.meta.seed,
turn_id: `${state.meta.seed}:${record.turn}`,
turn: record.turn,
stage: record.stage,
event_id: record.eventId,
action_taken: record.optionId,
action_label: record.optionLabel,
changes: record.changes,
upkeep: record.upkeep,
offered: record.offered,
rating: null,
comment: null,
desired_but_unavailable_action: null,
});
writeRaw(entries);
return entries.at(-1);
}
/**
* Attach the player's feedback to a turn already logged. Feedback arrives
* after the choice — often several seconds after — so it is a second write
* rather than part of the first.
*/
export function attachFeedback(turnId, { rating, comment, desiredAction }) {
const entries = readRaw();
const entry = entries.findLast((e) => e.turn_id === turnId);
if (!entry) return false;
if (rating !== undefined) entry.rating = rating;
if (comment !== undefined) entry.comment = comment || null;
if (desiredAction !== undefined) entry.desired_but_unavailable_action = desiredAction || null;
return writeRaw(entries);
}
export function readLog() {
return readRaw();
}
export function clearLog() {
return safely(() => { localStorage.removeItem(KEY); return true; }, false);
}
/** How many entries carry actual player feedback, for the export summary. */
export function feedbackCount(entries = readRaw()) {
return entries.filter((e) => e.rating || e.comment || e.desired_but_unavailable_action).length;
}
export function serializeLog(entries = readRaw()) {
return JSON.stringify({
format: FORMAT,
exported_at: new Date().toISOString(),
turns_logged: entries.length,
turns_with_feedback: feedbackCount(entries),
entries,
}, null, 2);
}
+77
View File
@@ -0,0 +1,77 @@
// Saving and loading.
//
// Local storage is the live save; the file export exists so a cleared cache or
// a crashed browser does not take a career with it. A save is the whole game
// state and nothing else — the engine keeps no state outside it, so this is a
// complete record by construction.
const KEY = 'theladder.save.v1';
const FORMAT = 1;
/** Local storage throws in private windows and when site data is blocked. */
function safely(action, fallback = null) {
try {
return action();
} catch {
return fallback;
}
}
export function saveLocal(state) {
return safely(() => {
localStorage.setItem(KEY, JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }));
return true;
}, false);
}
export function loadLocal() {
return safely(() => {
const raw = localStorage.getItem(KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return parsed?.state ?? null;
});
}
export function clearLocal() {
return safely(() => { localStorage.removeItem(KEY); return true; }, false);
}
/** True if the browser will actually remember anything. */
export function storageAvailable() {
return safely(() => {
const probe = '__theladder_probe__';
localStorage.setItem(probe, '1');
localStorage.removeItem(probe);
return true;
}, false);
}
/** A save file's contents, as a string ready to be downloaded. */
export function serializeSave(state) {
return JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }, null, 2);
}
/**
* Parse an imported save, refusing anything that is not one. A file from a
* different pack is rejected by name rather than loaded into a game whose
* events and characters do not match it.
*/
export function parseSave(text, expectedPackId) {
let parsed;
try {
parsed = JSON.parse(text);
} catch {
throw new Error('That file is not valid JSON.');
}
const state = parsed?.state;
if (!state || typeof state !== 'object' || !state.meta || typeof state.turn !== 'number') {
throw new Error('That does not look like a saved game.');
}
if (expectedPackId && state.meta.packId !== expectedPackId) {
throw new Error(
`That save is from "${state.meta.packId}", but this is "${expectedPackId}".`,
);
}
return state;
}
+230
View File
@@ -0,0 +1,230 @@
// The application: wiring the engine to the screens.
//
// This is the one module that names a content pack, which makes it the seam a
// future pack-picker slots into. Everything below it takes the pack as an
// argument.
//
// Rendering is a full rebuild of the root element. At this size it is fast and
// removes a whole class of stale-view bug — with one deliberate exception: the
// feedback text fields update the model without re-rendering, because
// rebuilding the DOM under a cursor would throw the player out of the box they
// are typing in.
import { corporateLadder } from '../../content/corporateladder/index.js';
import { validatePack } from '../engine/validate.js';
import { resolveSchema } from '../engine/state.js';
import { startGame, currentTurn, takeTurn, findOptionById } from './engine-bridge.js';
import { turnScreen } from './screens/turn.js';
import { retrospectiveScreen } from './screens/retrospective.js';
import { fill } from './dom.js';
import { saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable } from '../io/save.js';
import { logTurn, attachFeedback, readLog, serializeLog, clearLog, feedbackCount } from '../io/feedback-log.js';
import { downloadText, readFile, stamp } from '../io/download.js';
const pack = corporateLadder;
const schema = resolveSchema(pack);
const app = {
state: null,
view: null,
root: null,
};
/** Reset the per-turn view, reading the current turn out of the game state. */
function choosingView() {
return {
screen: 'turn',
phase: 'choosing',
turn: currentTurn(pack, app.state),
feedback: emptyFeedback(),
feedbackOpen: false,
};
}
const emptyFeedback = () =>
({ rating: null, comment: '', desiredAction: '', saved: false, touched: false, turnId: null });
function render() {
const screen = app.view.screen === 'retrospective'
? retrospectiveScreen(pack, app.state, schema, actions)
: turnScreen(pack, app.state, schema, app.view, actions);
fill(app.root, screen);
}
const actions = {
onChoose(optionId) {
const option = findOptionById(app.view.turn.event, optionId);
const { state, record } = takeTurn(pack, app.state, optionId);
app.state = state;
saveLocal(state);
const entry = logTurn(record, state, pack);
app.view = {
screen: 'turn',
phase: 'outcome',
turn: app.view.turn,
record,
option,
feedback: { ...emptyFeedback(), turnId: entry?.turn_id ?? null },
feedbackOpen: false,
};
render();
},
onNext() {
flushFeedback();
app.view = choosingView();
render();
},
onRate(rating) {
app.view.feedback.rating = app.view.feedback.rating === rating ? null : rating;
app.view.feedback.saved = app.view.feedback.rating !== null;
app.view.feedback.touched = true;
flushFeedback();
render();
},
// No render: rebuilding the DOM would move the cursor out of the field.
onComment(text) {
app.view.feedback.comment = text;
app.view.feedback.touched = true;
scheduleFlush();
},
onDesiredAction(text) {
app.view.feedback.desiredAction = text;
app.view.feedback.touched = true;
scheduleFlush();
},
onToggleFeedbackDetail() {
app.view.feedbackOpen = !app.view.feedbackOpen;
render();
},
onRetrospective() {
flushFeedback();
app.view = { ...app.view, screen: 'retrospective' };
render();
},
onResume() {
app.view = choosingView();
render();
},
onNewGame() {
const played = app.state?.history?.length ?? 0;
if (played > 0 && !confirm(
`This ends the current run at ${pack.turnUnit} ${app.state.turn}. ` +
'Export a save first if you want to keep it. Start a new game?',
)) return;
flushFeedback();
app.state = startGame(pack, { seed: String(Date.now()), schema, now: new Date().toISOString() });
clearLocal();
saveLocal(app.state);
app.view = choosingView();
render();
},
onExportSave() {
downloadText(`theladder-save-${stamp()}.json`, serializeSave(app.state));
},
onImportSave() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (!file) return;
try {
const state = parseSave(await readFile(file), pack.id);
app.state = state;
saveLocal(state);
app.view = choosingView();
render();
} catch (error) {
alert(`That save could not be loaded.\n\n${error.message}`);
}
});
input.click();
},
onExportFeedback() {
flushFeedback();
const entries = readLog();
if (entries.length === 0) {
alert('Nothing logged yet — play a turn or two first.');
return;
}
downloadText(`theladder-feedback-${stamp()}.json`, serializeLog(entries));
const withFeedback = feedbackCount(entries);
if (confirm(
`Exported ${entries.length} logged turns (${withFeedback} carry feedback).\n\n` +
'Clear the log now that it is saved?',
)) clearLog();
},
};
// Text fields write through on a delay so a long comment is not one storage
// write per keystroke, and always flush before anything that could lose it.
let flushTimer = null;
function scheduleFlush() {
clearTimeout(flushTimer);
flushTimer = setTimeout(flushFeedback, 500);
}
function flushFeedback() {
clearTimeout(flushTimer);
const feedback = app.view?.feedback;
if (!feedback?.turnId) return;
// `touched` rather than "has content": a player who clears a rating or
// deletes a comment is withdrawing it, and that has to be written through
// too. Turns the player simply ignored are never written at all.
if (!feedback.touched) return;
attachFeedback(feedback.turnId, {
rating: feedback.rating,
comment: feedback.comment,
desiredAction: feedback.desiredAction,
});
}
export function boot(rootElement) {
const validation = validatePack(pack);
if (!validation.valid) {
fill(rootElement, errorScreen(validation.errors));
return;
}
for (const warning of validation.warnings) console.warn(`[content] ${warning}`);
if (!storageAvailable()) {
console.warn('[save] local storage is unavailable; progress will not persist. Use Export save.');
}
app.root = rootElement;
const restored = loadLocal();
app.state = restored && restored.meta?.packId === pack.id
? restored
: startGame(pack, { seed: String(Date.now()), schema, now: new Date().toISOString() });
app.view = choosingView();
render();
// Anything half-typed survives a tab close.
window.addEventListener('beforeunload', flushFeedback);
}
function errorScreen(errors) {
const list = document.createElement('ul');
for (const error of errors) {
const item = document.createElement('li');
item.textContent = error;
list.append(item);
}
const wrapper = document.createElement('div');
wrapper.className = 'screen screen--error';
const heading = document.createElement('h1');
heading.textContent = 'This content pack will not load.';
wrapper.append(heading, list);
return wrapper;
}
+92
View File
@@ -0,0 +1,92 @@
// Pieces shared between the turn screen and the retrospective.
import { h } from './dom.js';
import { formatValue, labelFor, headerStats } from './format.js';
import { getPath } from '../engine/paths.js';
/** A labelled bar for a bounded stat. */
export function meter(label, value, bounds, { compact = false } = {}) {
const min = bounds?.min ?? 0;
const max = bounds?.max ?? 100;
const fraction = max === min ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min)));
return h('div', { class: compact ? 'meter meter--compact' : 'meter' },
h('div', { class: 'meter__head' },
h('span', { class: 'meter__label' }, label),
h('span', { class: 'meter__value' }, String(value)),
),
h('div', { class: 'meter__track', role: 'img', 'aria-label': `${label}: ${value} of ${max}` },
h('div', { class: 'meter__fill', style: `width: ${(fraction * 100).toFixed(1)}%` }),
),
);
}
/** The stat strip: money and debt as figures, bounded stats as bars. */
export function statPanel(pack, state, schema) {
return h('div', { class: 'stats' },
headerStats(pack, state, schema).map((stat) => (
stat.rule.format === 'meter'
? meter(stat.label, stat.value, stat.bounds, { compact: true })
: h('div', { class: `figure figure--${stat.path.replace(/\./g, '-')}` },
h('span', { class: 'figure__label' }, stat.label),
h('span', {
class: `figure__value${stat.value < 0 ? ' figure__value--negative' : ''}`,
}, formatValue(pack, stat.path, stat.value)),
)
)),
);
}
/** One character, with how they currently regard the player. */
export function characterCard(pack, state, schema, character) {
return h('article', { class: 'person' },
h('header', { class: 'person__head' },
h('h3', { class: 'person__name' }, character.name),
character.title && h('p', { class: 'person__title' }, character.title),
),
h('p', { class: 'person__description' }, character.description),
h('div', { class: 'person__meters' },
Object.keys(pack.relationshipStats ?? {}).map((stat) => {
const path = `relationships.${character.id}.${stat}`;
return meter(
pack.relationshipStats[stat].label ?? stat,
getPath(state, path),
schema[path],
{ compact: true },
);
}),
),
);
}
/** The change list shown after a choice: what moved, and by how much. */
export function changeList(pack, changes, { className = 'changes' } = {}) {
const meaningful = changes.filter((c) => c.from !== c.to);
if (!meaningful.length) return h('p', { class: 'changes changes--none' }, 'Nothing measurable changed.');
return h('ul', { class: className },
meaningful.map((change) => {
const rising = typeof change.to === 'number' && typeof change.from === 'number'
? change.to > change.from
: Boolean(change.to);
return h('li', { class: `change change--${rising ? 'up' : 'down'}` },
// Upkeep changes carry the label of the entry that caused them —
// "Rent", "Wages" — which is far more use than the path's own name.
h('span', { class: 'change__label' }, change.label ?? labelFor(pack, change.path)),
h('span', { class: 'change__delta' }, deltaText(pack, change)),
change.clamped && h('span', { class: 'change__note' }, 'at its limit'),
);
}),
);
}
function deltaText(pack, change) {
if (typeof change.to !== 'number' || typeof change.from !== 'number') {
return typeof change.to === 'boolean' ? (change.to ? 'now true' : 'no longer true') : 'changed';
}
const delta = change.to - change.from;
const money = pack.display?.[change.path]?.format === 'money';
const magnitude = money
? `$${Math.abs(Math.round(delta)).toLocaleString('en-US')}`
: String(Math.abs(delta));
return `${delta > 0 ? '+' : '−'}${magnitude}`;
}
+26
View File
@@ -0,0 +1,26 @@
// A very small DOM helper. Not a framework — just enough to build elements
// without a wall of createElement calls.
export function h(tag, props = {}, ...children) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(props ?? {})) {
if (value === null || value === undefined || value === false) continue;
if (key === 'class') el.className = value;
else if (key === 'dataset') Object.assign(el.dataset, value);
else if (key.startsWith('on') && typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else if (key in el && key !== 'list') el[key] = value;
else el.setAttribute(key, value);
}
for (const child of children.flat(Infinity)) {
if (child === null || child === undefined || child === false) continue;
el.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return el;
}
/** Replace an element's contents. */
export function fill(container, ...children) {
container.replaceChildren(...children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false));
return container;
}
+34
View File
@@ -0,0 +1,34 @@
// The UI's view of the engine, with the pack's schema bound in.
//
// Re-exported here rather than imported directly so the screens never have to
// thread a schema argument through, and so there is one obvious place to look
// for everything the interface is allowed to do to a game.
import { resolveSchema } from '../engine/state.js';
import * as game from '../engine/game.js';
import { findOption } from '../engine/select.js';
const schemaCache = new WeakMap();
function schemaFor(pack) {
if (!schemaCache.has(pack)) schemaCache.set(pack, resolveSchema(pack));
return schemaCache.get(pack);
}
export function startGame(pack, options = {}) {
return game.startGame(pack, { schema: schemaFor(pack), ...options });
}
export function currentTurn(pack, state) {
return game.currentTurn(pack, state);
}
export function takeTurn(pack, state, optionId, options = {}) {
return game.takeTurn(pack, state, optionId, {
schema: schemaFor(pack),
now: new Date().toISOString(),
...options,
});
}
export const findOptionById = findOption;
+151
View File
@@ -0,0 +1,151 @@
// Turning state paths and conditions into English.
//
// Display names, number formats and which stats appear in the header all come
// from the pack's `display` map, so a new setting renames and re-orders the
// interface without touching the UI. Anything the pack does not describe falls
// back to a derivation from the path itself, so content is never *required* to
// supply a label just to be playable.
import { getPath } from '../engine/paths.js';
import { check } from '../engine/conditions.js';
const titleCase = (text) =>
text.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
/** The pack's display rules for a path, if it has any. */
export function displayFor(pack, path) {
return pack.display?.[path] ?? null;
}
/** A human name for a path, derived from the path when the pack is silent. */
export function labelFor(pack, path) {
const declared = displayFor(pack, path)?.label;
if (declared) return declared;
const segments = path.split('.');
if (segments[0] === 'relationships' && segments.length === 3) {
const character = pack.characters?.find((c) => c.id === segments[1]);
const stat = pack.relationshipStats?.[segments[2]]?.label ?? titleCase(segments[2]);
return `${character?.name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`;
}
return titleCase(segments.at(-1));
}
/** Money is written as money; everything else is a plain number. */
export function formatValue(pack, path, value) {
if (typeof value === 'boolean') return value ? 'yes' : 'no';
if (displayFor(pack, path)?.format === 'money') return formatMoney(value);
return String(value);
}
export function formatMoney(amount) {
const rounded = Math.round(amount);
const body = `$${Math.abs(rounded).toLocaleString('en-US')}`;
return rounded < 0 ? `−${body}` : body;
}
/** A signed change, for the outcome list: "+$45", "−3". */
export function formatDelta(pack, path, from, to) {
const delta = to - from;
if (displayFor(pack, path)?.format === 'money') {
return `${delta > 0 ? '+' : '−'}$${Math.abs(Math.round(delta)).toLocaleString('en-US')}`;
}
return `${delta > 0 ? '+' : '−'}${Math.abs(delta)}`;
}
/**
* One condition as a requirement the player can read: "Negotiation 15+",
* "Trevor — likes you 48+", "not in debt".
*/
export function describeCondition(pack, condition) {
if (!condition || typeof condition !== 'object') return '';
if (Array.isArray(condition)) return condition.map((c) => describeCondition(pack, c)).join(', ');
if (condition.all) return condition.all.map((c) => describeCondition(pack, c)).join(', ');
if (condition.any) return condition.any.map((c) => describeCondition(pack, c)).join(' or ');
if (condition.not) return `not ${describeCondition(pack, condition.not)}`;
const { path, op, value } = condition;
const label = labelFor(pack, path);
if (typeof value === 'boolean') return value ? label.toLowerCase() : `no ${label.toLowerCase()}`;
const amount = formatValue(pack, path, value);
switch (op) {
case '>=': return `${label} ${amount}+`;
case '>': return `${label} over ${amount}`;
case '<=': return `${label} ${amount} or less`;
case '<': return `${label} under ${amount}`;
case '==': return `${label} exactly ${amount}`;
case '!=': return `${label} other than ${amount}`;
default: return `${label} ${op} ${amount}`;
}
}
/** What a locked option is waiting for. */
export function requirementText(pack, failedConditions) {
const parts = failedConditions.map((c) => describeCondition(pack, c)).filter(Boolean);
return parts.length ? `Needs ${parts.join(', ')}` : 'Not available yet';
}
/**
* The stats the header shows, in the pack's declared order, skipping anything
* the pack marks hidden or asks to hide while it is zero.
*/
export function headerStats(pack, state, schema) {
return Object.entries(pack.display ?? {})
.filter(([, rule]) => !rule.hidden)
.map(([path, rule]) => ({
path,
rule,
label: labelFor(pack, path),
value: getPath(state, path),
bounds: schema[path],
}))
.filter((stat) => !(stat.rule.hideWhenZero && !stat.value))
.sort((a, b) => (a.rule.order ?? 99) - (b.rule.order ?? 99));
}
/**
* How the current turn is titled. A pack with a calendar gets its day name and
* cycle ("Thursday", "Week 2 · Day 12"); one without falls back to the turn
* number alone, which is what the engine guarantees.
*/
export function turnHeading(pack, state) {
const unit = capitalize(pack.turnUnit ?? 'turn');
const name = state.calendar?.name;
if (!name) return { title: `${unit} ${state.turn}`, detail: null };
const cycleName = pack.calendar?.cycleName ?? 'Cycle';
return {
title: name,
detail: `${cycleName} ${state.calendar.cycle} · ${unit} ${state.turn}`,
};
}
/**
* The pack's scheduled upkeep, as "what is coming and when". Answers the
* question a player actually has — when am I next paid, when is rent due —
* without them having to reverse-engineer it from the ledger.
*/
export function upcomingUpkeep(pack, state, { limit = 3 } = {}) {
const upcoming = [];
for (const entry of pack.upkeep ?? []) {
if (!entry.every || !entry.label) continue;
if (!check(state, entry.requires).passed) continue;
const offset = entry.offset ?? 0;
const elapsed = state.turn - 1 - offset;
const turnsAway = elapsed < 0 ? -elapsed : (entry.every - (elapsed % entry.every)) % entry.every;
const amount = (entry.effects ?? [])
.filter((e) => e.op === 'add' && typeof e.value === 'number')
.reduce((sum, e) => sum + e.value, 0);
upcoming.push({ label: entry.label, turnsAway, incoming: amount > 0 });
}
return upcoming
.sort((a, b) => a.turnsAway - b.turnsAway)
.filter((entry, index, all) => all.findIndex((e) => e.label === entry.label) === index)
.slice(0, limit);
}
const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
+87
View File
@@ -0,0 +1,87 @@
// The career retrospective.
//
// Structurally identical however the run went — this is a summary, not a
// scoreboard, and there is no win state to congratulate anyone on. It is also
// not an ending: the player can close it and carry on from the same turn.
import { h } from '../dom.js';
import { statPanel, characterCard } from '../components.js';
import { formatMoney, labelFor } from '../format.js';
import { summarize } from '../summary.js';
export function retrospectiveScreen(pack, state, schema, actions) {
const summary = summarize(pack, state);
const unit = pack.turnUnit ?? 'turn';
return h('div', { class: 'screen screen--retrospective' },
h('header', { class: 'retro__head' },
h('p', { class: 'retro__eyebrow' }, pack.name),
h('h1', { class: 'retro__title' }, 'Career retrospective'),
h('p', { class: 'retro__subtitle' },
`${summary.turns} ${unit}${summary.turns === 1 ? '' : 's'} in the ` +
`${(pack.stages?.[summary.stage]?.label ?? summary.stage).toLowerCase()}.`),
),
h('section', { class: 'retro__section' },
h('h2', { class: 'retro__heading' }, 'Where you ended up'),
statPanel(pack, state, schema),
),
h('section', { class: 'retro__section' },
h('h2', { class: 'retro__heading' }, 'How it went'),
h('dl', { class: 'facts' },
fact(`Highest your ${primaryLabel(pack, summary)} got`,
summary.peak === null ? '—' : formatMoney(summary.peak)),
fact('Lowest it got', summary.trough === null ? '—' : formatMoney(summary.trough)),
summary.daysInTheRed > 0 && fact(`${capitalize(unit)}s in the red`, String(summary.daysInTheRed)),
summary.bestDay && fact(`Best ${unit}`,
`${formatMoney(summary.bestDay.net)} — ${summary.bestDay.label}`),
summary.worstDay && fact(`Worst ${unit}`,
`${formatMoney(summary.worstDay.net)} — ${summary.worstDay.label}`),
summary.favouriteAction && fact('What you did most',
`${summary.favouriteAction.label} (${summary.favouriteAction.count}×)`),
fact('Situations you saw', `${summary.eventsSeen} of ${summary.eventsTotal}`),
summary.lockedEncounters > 0 && fact('Doors you could not open yet',
String(summary.lockedEncounters)),
),
),
h('section', { class: 'retro__section' },
h('h2', { class: 'retro__heading' }, 'Where you stand with people'),
h('div', { class: 'retro__people' },
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
),
),
h('section', { class: 'retro__section' },
h('h2', { class: 'retro__heading' }, 'Notable moments'),
summary.notable.length
? h('ol', { class: 'moments' }, summary.notable.map((moment) => h('li', { class: 'moment' }, moment)))
: h('p', { class: 'moments moments--none' },
'Nothing that anybody would write down. That happens.'),
),
h('footer', { class: 'controls' },
h('button', { class: 'button button--primary', onClick: actions.onResume },
'Keep going'),
h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'),
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
'Export feedback log'),
h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'Start again'),
),
);
}
/** The pack's own word for its main resource, lowercased for mid-sentence use. */
function primaryLabel(pack, summary) {
return summary.primaryPath ? labelFor(pack, summary.primaryPath).toLowerCase() : 'balance';
}
function fact(label, value) {
return h('div', { class: 'facts__item' },
h('dt', { class: 'facts__label' }, label),
h('dd', { class: 'facts__value' }, value),
);
}
const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
+171
View File
@@ -0,0 +1,171 @@
// The turn screen, in two phases.
//
// Choosing: the scenario and its options, with locked ones shown greyed and
// labelled with what they are waiting for — a player should be able to see the
// door they cannot open yet, because that is most of what makes the numbers
// mean anything.
//
// Outcome: what happened, what moved, and what the day cost regardless of the
// choice. The feedback widget lives here rather than in the choosing phase,
// because this is the moment a player has an opinion.
import { h } from '../dom.js';
import { statPanel, characterCard, changeList } from '../components.js';
import { requirementText, turnHeading, upcomingUpkeep } from '../format.js';
export function turnScreen(pack, state, schema, view, actions) {
const heading = turnHeading(pack, state);
return h('div', { class: 'screen screen--turn' },
h('header', { class: 'hud' },
h('div', { class: 'hud__where' },
h('p', { class: 'hud__stage' }, pack.stages?.[state.stage]?.title ?? state.stage),
h('h1', { class: 'hud__day' }, heading.title),
heading.detail && h('p', { class: 'hud__detail' }, heading.detail),
),
statPanel(pack, state, schema),
),
diary(pack, state),
h('div', { class: 'layout' },
h('main', { class: 'scene' },
view.phase === 'outcome'
? outcomeView(pack, view, actions)
: choosingView(pack, view, actions),
),
h('aside', { class: 'people' },
h('h2', { class: 'people__heading' }, 'The mailroom'),
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
),
),
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.onImportSave }, 'Import save'),
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
'Export feedback log'),
h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'New game'),
),
);
}
/**
* What is coming and when. A player asked what the daily drain was for; the
* answer is that most of it is not daily, and this says so before the money
* moves rather than after.
*/
function diary(pack, state) {
const upcoming = upcomingUpkeep(pack, state);
if (!upcoming.length) return null;
const unit = pack.turnUnit ?? 'turn';
return h('p', { class: 'diary' },
upcoming.map((entry, index) => h('span', {
class: `diary__entry diary__entry--${entry.incoming ? 'in' : 'out'}`,
},
index > 0 ? ' · ' : '',
entry.label,
entry.turnsAway === 0
? ' today'
: entry.turnsAway === 1 ? ` tomorrow` : ` in ${entry.turnsAway} ${unit}s`,
)),
);
}
function choosingView(pack, view, actions) {
const { event, options } = view.turn;
const visible = options.filter((o) => !o.hidden);
return h('section', { class: 'scenario' },
h('p', { class: 'scenario__text' }, event.description),
h('ul', { class: 'options' },
visible.map(({ option, available, failed }) => h('li', { class: 'options__item' },
h('button', {
class: `option${available ? '' : ' option--locked'}`,
disabled: !available,
onClick: available ? () => actions.onChoose(option.id) : null,
},
h('span', { class: 'option__label' }, option.label),
!available && h('span', { class: 'option__requirement' }, requirementText(pack, failed)),
),
)),
),
);
}
function outcomeView(pack, view, actions) {
const { record, option } = view;
return h('section', { class: 'outcome' },
h('p', { class: 'outcome__chose' }, option.label),
option.result && h('p', { class: 'outcome__text' }, option.result),
h('div', { class: 'outcome__ledger' },
h('h3', { class: 'outcome__heading' }, 'What that changed'),
changeList(pack, record.changes),
record.upkeep.length > 0 && h('div', { class: 'outcome__upkeep' },
h('h3', { class: 'outcome__heading' }, 'And then the day happened'),
changeList(pack, record.upkeep),
),
),
feedbackWidget(view, actions),
h('button', { class: 'button button--primary button--next', onClick: actions.onNext },
`Next ${pack.turnUnit ?? 'turn'}`),
);
}
// Entirely optional: ignoring it does nothing at all, which is the point.
function feedbackWidget(view, actions) {
const { feedback } = view;
const ratingButton = (value, label) => h('button', {
class: `rating${feedback.rating === value ? ' rating--on' : ''}`,
type: 'button',
'aria-pressed': String(feedback.rating === value),
onClick: () => actions.onRate(value),
}, label);
return h('section', { class: 'feedback' },
h('div', { class: 'feedback__row' },
h('span', { class: 'feedback__prompt' }, 'How was that?'),
h('div', { class: 'feedback__ratings' },
ratingButton('up', '👍'),
ratingButton('neutral', '😐'),
ratingButton('down', '👎'),
),
h('button', {
class: 'feedback__toggle',
type: 'button',
onClick: actions.onToggleFeedbackDetail,
}, view.feedbackOpen ? 'Fewer options' : 'Say more'),
),
view.feedbackOpen && h('div', { class: 'feedback__detail' },
h('label', { class: 'field' },
h('span', { class: 'field__label' }, 'Anything you want to tell the designer?'),
h('textarea', {
class: 'field__input',
rows: 2,
value: feedback.comment ?? '',
onInput: (e) => actions.onComment(e.target.value),
}),
),
h('label', { class: 'field' },
h('span', { class: 'field__label' },
"Something you wanted to do that wasn't offered?"),
h('textarea', {
class: 'field__input',
rows: 2,
value: feedback.desiredAction ?? '',
onInput: (e) => actions.onDesiredAction(e.target.value),
}),
),
),
feedback.saved && h('p', { class: 'feedback__saved' }, 'Noted — thank you.'),
);
}
+289
View File
@@ -0,0 +1,289 @@
/* The Ladder — one stylesheet, no framework.
The look is office stationery: manila, rules, stamped figures. Muted enough
that seventeen scenarios in a row do not tire the eye. */
:root {
--paper: #f2eee4;
--card: #fffdf7;
--ink: #26231d;
--ink-soft: #6b6659;
--ink-faint: #938c7c;
--rule: #ded6c4;
--rule-strong: #c9bfa8;
--accent: #7c5a2c;
--accent-soft: #efe3cd;
--up: #4a6b3d;
--down: #a4402f;
--focus: #2f6f9e;
--radius: 3px;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--serif: Georgia, "Iowan Old Style", "Palatino Linotype", Palatino, serif;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #1c1a17;
--card: #24211d;
--ink: #e8e3d8;
--ink-soft: #a8a294;
--ink-faint: #7d776a;
--rule: #38342d;
--rule-strong: #4a453b;
--accent: #c9a05e;
--accent-soft: #3a3227;
--up: #85ad72;
--down: #d17a68;
--focus: #6fa8d4;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.app { max-width: 1080px; margin: 0 auto; padding: 24px 20px 48px; }
.booting { color: var(--ink-faint); padding: 48px 20px; text-align: center; }
/* ---------- head-up display ---------- */
.hud {
display: flex;
flex-wrap: wrap;
gap: 20px 32px;
align-items: flex-end;
justify-content: space-between;
padding-bottom: 16px;
border-bottom: 2px solid var(--rule-strong);
}
.hud__stage {
margin: 0;
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-faint);
}
.hud__day { margin: 2px 0 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; }
.hud__detail { margin: 1px 0 0; font-size: 13px; color: var(--ink-soft); }
.diary {
margin: 14px 0 0; padding: 8px 12px;
font-size: 12.5px; color: var(--ink-soft);
background: var(--card); border: 1px solid var(--rule); border-radius: var(--radius);
}
.diary__entry--in { color: var(--up); }
.diary__entry--out { color: var(--ink-soft); }
.stats { display: flex; flex-wrap: wrap; gap: 10px 26px; align-items: flex-end; }
.figure { display: flex; flex-direction: column; gap: 1px; min-width: 92px; }
.figure__label {
font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint);
}
.figure__value { font-family: var(--serif); font-size: 21px; font-variant-numeric: tabular-nums; }
.figure__value--negative { color: var(--down); }
/* ---------- meters ---------- */
.meter { min-width: 150px; }
.meter--compact { min-width: 132px; }
.meter__head {
display: flex; justify-content: space-between; gap: 10px; align-items: baseline;
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint);
}
.meter__value { font-variant-numeric: tabular-nums; color: var(--ink-soft); }
.meter__track {
margin-top: 4px; height: 5px; background: var(--rule); border-radius: 99px; overflow: hidden;
}
.meter__fill {
height: 100%; background: var(--accent); border-radius: 99px;
transition: width 280ms ease;
}
/* ---------- layout ---------- */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 36px;
margin-top: 28px;
align-items: start;
}
@media (max-width: 860px) {
.layout { grid-template-columns: minmax(0, 1fr); gap: 28px; }
}
/* ---------- the scenario ---------- */
.scenario__text, .outcome__text {
font-family: var(--serif);
font-size: 20px;
line-height: 1.55;
margin: 0 0 24px;
}
.options { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.option {
display: flex; flex-direction: column; gap: 3px; width: 100%;
padding: 14px 16px; text-align: left; cursor: pointer;
background: var(--card); color: inherit; font: inherit;
border: 1px solid var(--rule-strong); border-radius: var(--radius);
transition: border-color 120ms ease, transform 120ms ease, background 120ms ease;
}
.option:hover:not(:disabled) { border-color: var(--accent); transform: translateX(2px); }
.option:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.option__label { font-size: 16px; }
.option--locked {
cursor: not-allowed; background: transparent; border-style: dashed;
border-color: var(--rule); color: var(--ink-faint);
}
.option__requirement {
font-size: 12px; letter-spacing: 0.04em; color: var(--ink-faint); font-style: italic;
}
/* ---------- outcome ---------- */
.outcome__chose {
margin: 0 0 10px; font-size: 12px; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--accent);
}
.outcome__ledger {
margin: 26px 0; padding: 18px 20px;
background: var(--card); border: 1px solid var(--rule); border-radius: var(--radius);
}
.outcome__heading {
margin: 0 0 10px; font-size: 11px; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--ink-faint); font-weight: 600;
}
.outcome__upkeep { margin-top: 18px; padding-top: 16px; border-top: 1px dashed var(--rule); }
.changes { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; }
.changes--none { color: var(--ink-faint); font-style: italic; margin: 0; }
.change { display: flex; gap: 10px; align-items: baseline; font-size: 14px; }
.change__label { color: var(--ink-soft); }
.change__delta { font-variant-numeric: tabular-nums; font-weight: 600; margin-left: auto; }
.change--up .change__delta { color: var(--up); }
.change--down .change__delta { color: var(--down); }
.change__note { font-size: 11px; color: var(--ink-faint); font-style: italic; }
.button--next { margin-top: 8px; }
/* ---------- people ---------- */
.people__heading, .retro__heading {
margin: 0 0 12px; font-size: 11px; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--ink-faint); font-weight: 600;
}
.person {
padding: 16px; margin-bottom: 14px;
background: var(--card); border: 1px solid var(--rule); border-radius: var(--radius);
}
.person__name { margin: 0; font-size: 16px; font-weight: 600; }
.person__title {
margin: 1px 0 0; font-size: 11px; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--ink-faint);
}
.person__description {
margin: 10px 0 14px; font-size: 13px; line-height: 1.5; color: var(--ink-soft);
font-family: var(--serif);
}
.person__meters { display: grid; gap: 8px; }
/* ---------- feedback ---------- */
.feedback {
margin: 24px 0; padding: 14px 16px;
border: 1px dashed var(--rule-strong); border-radius: var(--radius);
}
.feedback__row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.feedback__prompt { font-size: 13px; color: var(--ink-soft); }
.feedback__ratings { display: flex; gap: 6px; }
.rating {
padding: 4px 10px; font-size: 16px; line-height: 1.2; cursor: pointer;
background: transparent; border: 1px solid var(--rule); border-radius: var(--radius);
filter: grayscale(1); opacity: 0.65; transition: all 120ms ease;
}
.rating:hover { opacity: 1; filter: none; }
.rating--on { filter: none; opacity: 1; border-color: var(--accent); background: var(--accent-soft); }
.rating:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.feedback__toggle {
margin-left: auto; padding: 0; font: inherit; font-size: 12px; cursor: pointer;
background: none; border: none; color: var(--ink-faint); text-decoration: underline;
}
.feedback__detail { display: grid; gap: 12px; margin-top: 14px; }
.field { display: grid; gap: 4px; }
.field__label { font-size: 12px; color: var(--ink-faint); }
.field__input {
width: 100%; padding: 8px 10px; font: inherit; font-size: 14px; resize: vertical;
background: var(--card); color: inherit;
border: 1px solid var(--rule); border-radius: var(--radius);
}
.field__input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.feedback__saved { margin: 10px 0 0; font-size: 12px; color: var(--up); }
/* ---------- buttons and controls ---------- */
.controls {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
margin-top: 36px; padding-top: 18px; border-top: 1px solid var(--rule);
}
.button {
padding: 9px 15px; font: inherit; font-size: 14px; cursor: pointer;
background: var(--card); color: inherit;
border: 1px solid var(--rule-strong); border-radius: var(--radius);
transition: border-color 120ms ease, background 120ms ease;
}
.button:hover { border-color: var(--accent); }
.button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.button--primary { background: var(--accent); border-color: var(--accent); color: #fffdf7; }
.button--primary:hover { filter: brightness(1.08); }
.button--quiet { border-color: transparent; background: transparent; color: var(--ink-faint); }
.button--quiet:hover { color: var(--ink); border-color: var(--rule); }
/* ---------- retrospective ---------- */
.retro__head { padding-bottom: 20px; border-bottom: 2px solid var(--rule-strong); }
.retro__eyebrow {
margin: 0; font-size: 11px; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--ink-faint);
}
.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__section { margin-top: 34px; }
.retro__people { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
.retro__people .person { margin-bottom: 0; }
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; margin: 0; }
.facts__item {
padding: 12px 14px; background: var(--card);
border: 1px solid var(--rule); border-radius: var(--radius);
}
.facts__label {
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint);
}
.facts__value { margin: 3px 0 0; font-family: var(--serif); font-size: 16px; }
.moments { margin: 0; padding-left: 22px; display: grid; gap: 7px; }
.moment { font-family: var(--serif); font-size: 16px; }
.moments--none { padding: 0; color: var(--ink-faint); font-style: italic; }
.screen--error { padding: 40px 0; }
.screen--error h1 { font-size: 20px; color: var(--down); }
+67
View File
@@ -0,0 +1,67 @@
// Reading a career back out of its history.
//
// Everything here is derived generically from the turn record and the pack's
// display map — no path is named. A pack with different stats, or none of them
// called money, gets the same retrospective for free.
/** Paths the pack formats as money, in display order. */
function moneyPaths(pack) {
return Object.entries(pack.display ?? {})
.filter(([, rule]) => rule.format === 'money')
.sort((a, b) => (a[1].order ?? 99) - (b[1].order ?? 99))
.map(([path]) => path);
}
/** Every value a path passed through, turn by turn. */
function trace(history, path) {
const values = [];
for (const record of history) {
for (const change of [...record.changes, ...record.upkeep]) {
if (change.path === path && typeof change.to === 'number') values.push(change.to);
}
}
return values;
}
export function summarize(pack, state) {
const history = state.history ?? [];
const primary = moneyPaths(pack)[0] ?? null;
const primaryTrace = primary ? trace(history, primary) : [];
const byLabel = new Map();
for (const record of history) {
byLabel.set(record.optionLabel, (byLabel.get(record.optionLabel) ?? 0) + 1);
}
const favourite = [...byLabel.entries()].sort((a, b) => b[1] - a[1])[0] ?? null;
const netByTurn = history.map((record) => ({
turn: record.turn,
label: record.optionLabel,
net: [...record.changes, ...record.upkeep]
.filter((c) => c.path === primary && typeof c.to === 'number')
.reduce((sum, c) => sum + (c.to - c.from), 0),
}));
const ranked = [...netByTurn].sort((a, b) => b.net - a.net);
const lockedEncounters = history.reduce(
(sum, record) => sum + record.offered.filter((o) => !o.available).length, 0,
);
const eventsSeen = new Set(history.map((r) => r.eventId)).size;
return {
turns: history.length,
stage: state.stage,
notable: state.notable ?? [],
primaryPath: primary,
peak: primaryTrace.length ? Math.max(...primaryTrace) : null,
trough: primaryTrace.length ? Math.min(...primaryTrace) : null,
daysInTheRed: primaryTrace.filter((value) => value < 0).length,
favouriteAction: favourite ? { label: favourite[0], count: favourite[1] } : null,
bestDay: ranked[0]?.net > 0 ? ranked[0] : null,
worstDay: ranked.at(-1)?.net < 0 ? ranked.at(-1) : null,
lockedEncounters,
eventsSeen,
eventsTotal: (pack.events ?? []).length,
};
}
+47
View File
@@ -0,0 +1,47 @@
// The single-file build.
//
// A build that produces a file proves nothing; this one runs the bundled
// script against the fake DOM and plays a turn through it. That is the closest
// thing to opening dist/theladder.html that is available without a browser —
// it catches a broken bundle, which is otherwise only discovered by whoever
// was handed the file.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { installFakeDom, byClass, firstByClass } from './fixtures/fake-dom.js';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
test('the bundle builds, and the bundled game boots and plays', () => {
execFileSync('node', ['tools/build.js'], { cwd: root, stdio: 'pipe' });
const html = readFileSync(resolve(root, 'dist/theladder.html'), 'utf8');
assert.doesNotMatch(html, /<link\s+rel="stylesheet"/, 'the stylesheet is inlined');
assert.doesNotMatch(html, /type="module"/, 'no module scripts survive; file:// would block them');
assert.doesNotMatch(html, /^[ \t]*import\s/m, 'no bare import statements remain');
assert.match(html, /--paper:/, 'the CSS made it in');
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
assert.ok(script, 'the bundled script is present');
const dom = installFakeDom();
try {
// eslint-disable-next-line no-new-func -- running the build output is the point
new Function(script)();
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the bundled game rendered a turn');
assert.match(dom.root.textContent, /Day 1/);
assert.match(dom.root.textContent, /Marlene Hobb/);
const playable = byClass(dom.root, 'option').filter((b) => !b.disabled);
assert.ok(playable.length > 0, 'the bundled game offers choices');
playable[0].click();
assert.ok(firstByClass(dom.root, 'outcome'), 'a turn can be played in the bundle');
} finally {
dom.restore();
}
});
+79
View File
@@ -0,0 +1,79 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { evaluate, check, pathsUsed } from '../src/engine/conditions.js';
const state = {
money: 100,
turn: 12,
skills: { talking: 3 },
flags: { in_debt: false },
notable: ['promoted'],
seen: { first_day: 1 },
};
test('compares numbers', () => {
assert.equal(evaluate(state, { path: 'money', op: '>=', value: 100 }), true);
assert.equal(evaluate(state, { path: 'money', op: '>', value: 100 }), false);
assert.equal(evaluate(state, { path: 'skills.talking', op: '<', value: 5 }), true);
});
test('compares booleans and equality', () => {
assert.equal(evaluate(state, { path: 'flags.in_debt', op: '==', value: false }), true);
assert.equal(evaluate(state, { path: 'flags.in_debt', op: '!=', value: true }), true);
});
test('array membership works both directions', () => {
assert.equal(evaluate(state, { path: 'notable', op: 'has', value: 'promoted' }), true);
assert.equal(evaluate(state, { path: 'notable', op: 'not-has', value: 'fired' }), true);
assert.equal(evaluate(state, { path: 'skills.talking', op: 'in', value: [1, 2, 3] }), true);
assert.equal(evaluate(state, { path: 'skills.talking', op: 'not-in', value: [9] }), true);
});
test('ordering against a missing path is false, not a throw', () => {
assert.equal(evaluate(state, { path: 'skills.welding', op: '>=', value: 1 }), false);
assert.equal(evaluate(state, { path: 'skills.welding', op: '<', value: 1 }), false);
});
test('content can read engine bookkeeping', () => {
assert.equal(evaluate(state, { path: 'turn', op: '>=', value: 10 }), true);
assert.equal(evaluate(state, { path: 'seen.first_day', op: '>=', value: 1 }), true);
assert.equal(evaluate(state, { path: 'seen.never_fired', op: '>=', value: 1 }), false);
});
test('combinators nest', () => {
const condition = {
all: [
{ path: 'money', op: '>', value: 0 },
{ any: [{ path: 'skills.talking', op: '>=', value: 3 }, { path: 'flags.in_debt', op: '==', value: true }] },
{ not: { path: 'notable', op: 'has', value: 'fired' } },
],
};
assert.equal(evaluate(state, condition), true);
assert.equal(evaluate({ ...state, money: -1 }, condition), false);
});
test('check reports which conditions failed', () => {
const result = check(state, [
{ path: 'money', op: '>=', value: 100 },
{ path: 'skills.talking', op: '>=', value: 9 },
]);
assert.equal(result.passed, false);
assert.equal(result.failed.length, 1);
assert.equal(result.failed[0].path, 'skills.talking');
});
test('an absent gate passes', () => {
assert.equal(check(state, undefined).passed, true);
assert.equal(check(state, []).passed, true);
});
test('an unknown operator is a loud failure', () => {
assert.throws(() => evaluate(state, { path: 'money', op: '=~', value: 1 }), /unknown condition operator/);
});
test('pathsUsed walks the whole tree', () => {
const paths = pathsUsed({
all: [{ path: 'money', op: '>', value: 0 }, { not: { path: 'flags.in_debt', op: '==', value: true } }],
});
assert.deepEqual(paths.sort(), ['flags.in_debt', 'money']);
});
+181
View File
@@ -0,0 +1,181 @@
// Tests for the shipped Corporate Ladder pack.
//
// These are content tests, not engine tests: they assert that the pack is
// playable, that none of it is dead, and that its economy behaves. Engine
// behaviour is covered against the fixture pack instead, so tuning the game
// cannot break the engine suite and vice versa.
//
// One trap, learned the hard way: the chooser's RNG must be seeded
// *differently* from the game's. Both draw one number per turn from the same
// generator, so sharing a seed correlates the choice index with the event
// draw and silently makes whole option combinations unreachable — which looks
// exactly like a content bug.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { corporateLadder as pack } from '../content/corporateladder/index.js';
import { validatePack } from '../src/engine/validate.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { createRng } from '../src/engine/rng.js';
import { play, sweep, median } from './fixtures/strategies.js';
/** Play `turns` turns with independently-seeded random choices. */
function randomRun(runSeed, turns, visit = () => {}) {
const rng = createRng(`chooser-${runSeed}`);
let state = startGame(pack, { seed: `run-${runSeed}` });
for (let t = 0; t < turns; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
assert.ok(available.length > 0, `no available option on event "${turn.event.id}"`);
const choice = rng.pick(available).option.id;
visit(turn, state, choice);
state = takeTurn(pack, state, choice).state;
}
return state;
}
test('the pack is valid, with no warnings', () => {
const result = validatePack(pack);
assert.deepEqual(result.errors, []);
assert.deepEqual(result.warnings, []);
});
test('no content is dead: every event and option is reachable by some player', () => {
// Across archetypes, not just random play: several things in the pack exist
// for a player pursuing something, and "broke but well-liked" in particular
// is a state no single-axis strategy ever reaches.
const { firedEvents, takenOptions } = sweep(pack, { seeds: 12, turns: 200 });
const allEvents = pack.events.map((e) => e.id);
const allOptions = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.id}`));
assert.deepEqual(allEvents.filter((e) => !firedEvents.has(e)), [], 'events that can never fire');
assert.deepEqual(allOptions.filter((o) => !takenOptions.has(o)), [], 'options that can never be taken');
});
test('how you play changes where you end up', () => {
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
const finalMoney = (name) => median(byStrategy[name].map((s) => s.money));
// The shape that matters: minding the money climbs, ignoring it sinks, and
// playing without a plan roughly treads water. Exact figures will move with
// tuning; the ordering is the design.
assert.ok(finalMoney('thrifty') > finalMoney('random'),
'a careful player should get ahead');
assert.ok(finalMoney('random') > finalMoney('spendthrift'),
'a careless player should struggle');
assert.ok(finalMoney('thrifty') > 800,
`a careful player should be comfortably ahead, was ${finalMoney('thrifty')}`);
});
test('hard times is uncommon for a careful player and real for a careless one', () => {
const countHardTimes = (strategy) => {
let days = 0;
play(pack, { strategy, seed: 'ht', turns: 200,
visit: ({ turn }) => { if (turn.event.id === 'rent_bounced') days++; } });
return days;
};
assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of trouble');
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
});
test('every turn always offers something to do, however badly the run is going', () => {
// randomRun asserts this on every turn; 40 long runs is the coverage.
for (let seed = 100; seed < 140; seed++) randomRun(seed, 150);
});
test('money arrives and leaves on a rhythm the player can plan around', () => {
const wages = [];
const rent = [];
let state = startGame(pack, { seed: 'rhythm' });
for (let t = 0; t < 30; t++) {
const turn = currentTurn(pack, state);
const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id);
for (const change of result.record.upkeep) {
if (change.label === 'Wages') wages.push(state.turn);
if (change.label === 'Rent') rent.push(state.turn);
}
state = result.state;
}
assert.deepEqual(wages, [5, 19], 'paid on alternate Fridays');
assert.deepEqual(rent, [8, 15, 22, 29], 'rent every Monday from the second week');
});
test('the calendar names the day and counts the week', () => {
let state = startGame(pack, { seed: 'calendar' });
assert.deepEqual(
[state.calendar.name, state.calendar.cycle, state.calendar.index],
['Monday', 1, 0],
);
for (let t = 0; t < 11; t++) {
const turn = currentTurn(pack, state);
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.equal(state.turn, 12);
assert.deepEqual([state.calendar.name, state.calendar.cycle], ['Friday', 2]);
});
test('hard times is survivable without any relationship or skill', () => {
const event = pack.events.find((e) => e.id === 'rent_bounced');
const ungated = event.options.filter((o) => !o.requires);
assert.ok(ungated.length > 0, 'a player with nothing must still have a way out');
});
test('the run recovers from hard times rather than sticking there', () => {
let sawNegative = false;
let recovered = false;
play(pack, { strategy: 'spendthrift', seed: 'recovery', turns: 200,
visit: ({ state }) => {
if (state.money < 0) sawNegative = true;
else if (sawNegative) recovered = true;
} });
assert.ok(sawNegative, 'a careless run should go broke');
assert.ok(recovered, 'and should be able to climb back out');
});
test('debt cannot spiral without limit: lending stops and the charge escalates', () => {
let peakDebt = 0;
for (const strategy of ['spendthrift', 'desperate', 'random']) {
for (let seed = 0; seed < 12; seed++) {
play(pack, { strategy, seed, turns: 200,
visit: ({ state }) => { peakDebt = Math.max(peakDebt, state.debt); } });
}
}
// The loan is gated at debt < 2000 and lends 500, so 2500 plus stalled
// interest is the ceiling. Well above that means the gate has stopped working.
assert.ok(peakDebt <= 3000, `debt reached ${peakDebt}; the borrowing ceiling is not holding`);
const charges = pack.upkeep.filter((u) => u.label?.startsWith('Loan service charge'));
assert.ok(charges.length >= 3, 'the service charge should step up as debt grows');
});
test('a deliberate player can reach relationship-gated content in a reasonable run', () => {
// The Trevor chain is the deepest gate in the pack. A player cultivating him
// should get there well inside a normal session, not at turn 100.
const regard = (o) => (o.option.effects ?? [])
.filter((e) => e.path === 'relationships.trevor.likes_you')
.reduce((sum, e) => sum + e.value, 0);
let state = startGame(pack, { seed: 'befriend-trevor' });
let reached = null;
for (let t = 0; t < 60 && reached === null; t++) {
const turn = currentTurn(pack, state);
if (turn.event.id === 'trevor_favor') { reached = state.turn; break; }
const best = turn.options.filter((o) => o.available).sort((a, b) => regard(b) - regard(a))[0];
state = takeTurn(pack, state, best.option.id).state;
}
assert.ok(reached && reached < 45, `Trevor's chain opened at turn ${reached ?? 'never'}`);
});
test('a played run replays identically from its seed', () => {
const a = randomRun(42, 80);
const b = randomRun(42, 80);
assert.deepEqual(a, b);
});
test('the retrospective has something to read back', () => {
const state = randomRun(3, 60);
assert.ok(state.notable.length > 0, 'notable moments should accumulate');
assert.equal(state.history.length, 60, 'every turn is recorded');
assert.ok(state.history.every((h) => Array.isArray(h.upkeep)), 'upkeep is recorded per turn');
});
+74
View File
@@ -0,0 +1,74 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { applyEffect, applyEffects } from '../src/engine/effects.js';
const schema = {
'money': { initial: 0, min: -500, max: 1000 },
'skills.talking': { initial: 1, min: 0, max: 10 },
'flags.in_debt': { initial: false },
'notable': { initial: [] },
};
const state = { money: 100, skills: { talking: 1 }, flags: { in_debt: false }, notable: [] };
test('add and set produce a new state', () => {
const result = applyEffects(state, [
{ path: 'money', op: 'add', value: -30 },
{ path: 'flags.in_debt', op: 'set', value: true },
], schema);
assert.equal(result.state.money, 70);
assert.equal(result.state.flags.in_debt, true);
assert.equal(state.money, 100, 'the input state is never mutated');
});
test('push appends to a list', () => {
const result = applyEffects(state, [{ path: 'notable', op: 'push', value: 'first day' }], schema);
assert.deepEqual(result.state.notable, ['first day']);
assert.deepEqual(state.notable, [], 'the input list is never mutated');
});
test('remove drops matching entries', () => {
const seeded = { ...state, notable: ['a', 'b', 'a'] };
const result = applyEffects(seeded, [{ path: 'notable', op: 'remove', value: 'a' }], schema);
assert.deepEqual(result.state.notable, ['b']);
});
test('values clamp to their declared bounds', () => {
const high = applyEffect(state, { path: 'skills.talking', op: 'add', value: 99 }, schema);
assert.equal(high.state.skills.talking, 10);
assert.equal(high.change.clamped, true);
const low = applyEffect(state, { path: 'money', op: 'add', value: -9999 }, schema);
assert.equal(low.state.money, -500);
});
test('an unbounded path is not clamped', () => {
const result = applyEffect(state, { path: 'money', op: 'add', value: 10 }, {});
assert.equal(result.state.money, 110);
assert.equal(result.change.clamped, false);
});
test('the change record carries before and after for every path touched', () => {
const result = applyEffects(state, [
{ path: 'money', op: 'add', value: 25 },
{ path: 'skills.talking', op: 'add', value: 1 },
], schema);
assert.deepEqual(result.changes.map((c) => [c.path, c.from, c.to]), [
['money', 100, 125],
['skills.talking', 1, 2],
]);
});
test('adding to a path that has never been set treats it as zero', () => {
const result = applyEffect({}, { path: 'money', op: 'add', value: 5 }, {});
assert.equal(result.state.money, 5);
});
test('an unknown operation is a loud failure', () => {
assert.throws(() => applyEffect(state, { path: 'money', op: 'multiply', value: 2 }), /unknown effect operation/);
});
test('an empty effect list is a no-op', () => {
const result = applyEffects(state, [], schema);
assert.deepEqual(result.changes, []);
assert.equal(result.state, state);
});
+162
View File
@@ -0,0 +1,162 @@
// A minimal DOM, enough to drive the UI headlessly.
//
// Not a browser and not a substitute for clicking the real thing — it exists
// so that the wiring (imports, handlers, the render/choose/log path) is
// exercised by the test suite rather than discovered by a player. Anything
// involving real files or real downloads is out of its scope and has to be
// checked by hand.
class FakeNode {
constructor() {
this.childNodes = [];
this.parentNode = null;
}
get textContent() {
return this.childNodes.map((child) => child.textContent).join('');
}
}
class FakeText extends FakeNode {
constructor(text) {
super();
this.nodeValue = String(text);
}
get textContent() {
return this.nodeValue;
}
}
class FakeElement extends FakeNode {
constructor(tagName) {
super();
this.tagName = tagName.toUpperCase();
this.className = '';
this.id = '';
this.attributes = {};
this.listeners = {};
this.dataset = {};
this.style = '';
// Predefined so `key in el` behaves the way it does in a real element:
// these are set as properties, everything else falls through to attributes.
this.disabled = false;
this.value = '';
this.rows = 0;
this.type = '';
this.accept = '';
this.href = '';
this.download = '';
this.files = null;
}
append(...nodes) {
for (const node of nodes) {
node.parentNode = this;
this.childNodes.push(node);
}
}
replaceChildren(...nodes) {
this.childNodes = [];
this.append(...nodes);
}
remove() {
const siblings = this.parentNode?.childNodes;
if (siblings) siblings.splice(siblings.indexOf(this), 1);
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return this.attributes[name] ?? null; }
addEventListener(type, handler) {
(this.listeners[type] ??= []).push(handler);
}
dispatch(type, event = {}) {
for (const handler of this.listeners[type] ?? []) handler({ target: this, ...event });
}
click() { this.dispatch('click'); }
}
function walk(node, out = []) {
out.push(node);
for (const child of node.childNodes) if (child instanceof FakeElement) walk(child, out);
return out;
}
/** Every element under `root` whose class list contains `className`. */
export function byClass(root, className) {
return walk(root).filter((el) => el.className.split(/\s+/).includes(className));
}
/** The first such element, or undefined. */
export function firstByClass(root, className) {
return byClass(root, className)[0];
}
/** Every element of a tag. */
export function byTag(root, tagName) {
return walk(root).filter((el) => el.tagName === tagName.toUpperCase());
}
class FakeStorage {
constructor() { this.map = new Map(); }
getItem(key) { return this.map.has(key) ? this.map.get(key) : null; }
setItem(key, value) { this.map.set(key, String(value)); }
removeItem(key) { this.map.delete(key); }
clear() { this.map.clear(); }
}
/**
* Install the fake DOM as globals and return a handle for the test. Call the
* returned `restore` afterwards so suites do not leak into each other.
*/
export function installFakeDom() {
const root = new FakeElement('div');
root.id = 'app';
const document = {
createElement: (tag) => new FakeElement(tag),
createTextNode: (text) => new FakeText(text),
getElementById: (id) => (id === 'app' ? root : null),
body: new FakeElement('body'),
};
const listeners = {};
const window = {
addEventListener: (type, handler) => { (listeners[type] ??= []).push(handler); },
};
const previous = {};
const globals = {
document,
window,
Node: FakeNode,
localStorage: new FakeStorage(),
requestAnimationFrame: (fn) => setTimeout(fn, 0),
confirm: () => true,
alert: () => {},
};
for (const [key, value] of Object.entries(globals)) {
previous[key] = globalThis[key];
globalThis[key] = value;
}
return {
document,
window,
root,
storage: globals.localStorage,
/** Fire a window event, e.g. beforeunload. */
fireWindow: (type) => { for (const handler of listeners[type] ?? []) handler({}); },
restore() {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete globalThis[key];
else globalThis[key] = value;
}
},
};
}
export { FakeElement, FakeText, FakeNode };
+87
View File
@@ -0,0 +1,87 @@
// Player archetypes for content probing.
//
// "Is this content reachable?" is not the same question as "does random play
// reach it?" — a random player is not a plausible player, and several things
// in a pack exist precisely for a player pursuing something. Coverage is
// therefore the union across archetypes, and each one is a rough caricature of
// a way somebody actually plays.
//
// NOTE: seed the chooser differently from the game. Both draw one number per
// turn from the same generator, so a shared seed correlates the choice with
// the event draw and silently makes combinations unreachable.
import { startGame, currentTurn, takeTurn } from '../../src/engine/game.js';
import { createRng } from '../../src/engine/rng.js';
const moneyGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path.startsWith('relationships.'))
.reduce((sum, e) => sum + e.value, 0);
const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0];
export const strategies = {
/** No plan at all. */
random: (options, rng) => rng.pick(options),
/** Takes the money every time. */
thrifty: (options) => best(options, moneyGain),
/** Spends it every time. */
spendthrift: (options) => best(options, (o) => -moneyGain(o)),
/** Chases standing with people. */
sociable: (options) => best(options, relationshipGain),
/**
* Broke but well-liked — the state the "ask a friend for money" options were
* written for, and one no single-axis strategy ever reaches.
*/
desperate: (options, rng) => (rng.next() < 0.25
? rng.pick(options)
: best(options, (o) => relationshipGain(o) - moneyGain(o) / 40)),
};
/** Play one run to `turns`, calling `visit` before each choice. */
export function play(pack, { strategy = 'random', seed = 'run', turns = 200, visit } = {}) {
const choose = strategies[strategy];
const rng = createRng(`chooser-${strategy}-${seed}`);
let state = startGame(pack, { seed: `game-${strategy}-${seed}` });
for (let t = 0; t < turns; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
if (available.length === 0) throw new Error(`no available option on "${turn.event.id}"`);
const chosen = choose(available, rng);
visit?.({ turn, state, chosen });
state = takeTurn(pack, state, chosen.option.id).state;
}
return state;
}
/** Play every archetype across several seeds, reporting what was reached. */
export function sweep(pack, { seeds = 12, turns = 200 } = {}) {
const firedEvents = new Set();
const takenOptions = new Set();
const byStrategy = {};
for (const strategy of Object.keys(strategies)) {
const finals = [];
for (let seed = 0; seed < seeds; seed++) {
const state = play(pack, {
strategy,
seed,
turns,
visit: ({ turn, chosen }) => {
firedEvents.add(turn.event.id);
takenOptions.add(`${turn.event.id}.${chosen.option.id}`);
},
});
finals.push(state);
}
byStrategy[strategy] = finals;
}
return { firedEvents, takenOptions, byStrategy };
}
export const median = (values) =>
[...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
+112
View File
@@ -0,0 +1,112 @@
// A deliberately small pack that exercises every engine feature: gates,
// once-only events, cooldowns, weighted draws, a conditional branch and a
// clamped stat. Engine tests run against this, never against real content, so
// tuning the game cannot break the test suite.
export const testPack = {
id: 'testpack',
name: 'Test Pack',
version: '1.0.0',
turnUnit: 'day',
startingStage: 'floor',
state: {
'money': { initial: 100, min: -500, max: 10000 },
'skills.talking': { initial: 1, min: 0, max: 10 },
'flags.in_debt': { initial: false },
'notable': { initial: [] },
},
relationshipStats: {
likes_you: { initial: 50, min: 0, max: 100 },
fears_you: { initial: 50, min: 0, max: 100 },
},
characters: [
{ id: 'chief', name: 'The Chief', role_type: 'boss', description: 'Tall, tired.' },
{ id: 'pat', name: 'Pat', role_type: 'rival', description: 'Smiles too much.' },
],
events: [
{
id: 'routine',
stage: 'floor',
weight: 100,
description: 'Another ordinary shift.',
options: [
{
id: 'work',
label: 'Put your head down and work.',
effects: [
{ path: 'money', op: 'add', value: 10 },
{ path: 'relationships.chief.likes_you', op: 'add', value: 2 },
],
},
{
id: 'charm',
label: 'Talk your way into an easier job.',
requires: [{ path: 'skills.talking', op: '>=', value: 3 }],
effects: [{ path: 'skills.talking', op: 'add', value: 1 }],
},
{
id: 'practice',
label: 'Chat with Pat all afternoon.',
effects: [
{ path: 'skills.talking', op: 'add', value: 1 },
{ path: 'money', op: 'add', value: -5 },
{ path: 'relationships.pat.likes_you', op: 'add', value: 3 },
],
},
],
},
{
id: 'first_day',
stage: 'floor',
weight: 1000,
once: true,
description: 'Your first day.',
options: [
{
id: 'arrive',
label: 'Arrive on time.',
effects: [{ path: 'notable', op: 'push', value: 'Showed up on time, once.' }],
},
],
},
{
id: 'hard_times',
stage: 'floor',
weight: 1000,
requires: [{ path: 'money', op: '<', value: 0 }],
description: 'Rent is due and you do not have it.',
options: [
{
id: 'loan',
label: 'Take the loan.',
effects: [
{ path: 'money', op: 'add', value: 200 },
{ path: 'flags.in_debt', op: 'set', value: true },
],
},
{
id: 'beg',
label: 'Ask Pat for help.',
effects: [
{ path: 'money', op: 'add', value: 50 },
{ path: 'relationships.pat.fears_you', op: 'add', value: -10 },
],
},
],
},
{
id: 'audit',
stage: 'floor',
weight: 5,
cooldown: 10,
description: 'An auditor is wandering the floor.',
options: [{ id: 'nod', label: 'Nod politely.', effects: [] }],
},
],
};
export default testPack;
+251
View File
@@ -0,0 +1,251 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startGame, currentTurn, takeTurn, MoveError } from '../src/engine/game.js';
import { getPath, setPath } from '../src/engine/paths.js';
import { testPack } from './fixtures/test-pack.js';
/** Play `turns` turns, always taking the first available option. */
function playThrough(seed, turns, chooser = (options) => options.find((o) => o.available).option.id) {
let state = startGame(testPack, { seed });
const records = [];
for (let i = 0; i < turns; i++) {
const turn = currentTurn(testPack, state);
const result = takeTurn(testPack, state, chooser(turn.options, turn.event, state));
state = result.state;
records.push(result.record);
}
return { state, records };
}
test('a new game starts on turn one with the pack defaults', () => {
const state = startGame(testPack, { seed: 'start' });
assert.equal(state.turn, 1);
assert.equal(state.stage, 'floor');
assert.equal(state.money, 100);
assert.equal(state.skills.talking, 1);
assert.equal(state.relationships.chief.likes_you, 50);
assert.equal(state.relationships.pat.fears_you, 50);
assert.ok(state.meta.currentEventId, 'an event is waiting before the first turn');
});
test('relationship state is generated per character, so adding one adds its stats', () => {
const state = startGame(testPack, { seed: 'chars' });
assert.deepEqual(Object.keys(state.relationships).sort(), ['chief', 'pat']);
assert.deepEqual(Object.keys(state.relationships.chief).sort(), ['fears_you', 'likes_you']);
});
test('taking a turn applies effects and advances the clock', () => {
let state = startGame(testPack, { seed: 'turn' });
const before = state.money;
const result = takeTurn(testPack, state, currentTurn(testPack, state).options[0].option.id);
assert.equal(result.state.turn, 2);
assert.notEqual(result.state.meta.currentEventId, undefined);
assert.ok(result.state.money !== before || result.record.changes.length >= 0);
});
test('the same seed and the same choices replay identically', () => {
const a = playThrough('replay', 40);
const b = playThrough('replay', 40);
assert.deepEqual(a.state, b.state);
assert.deepEqual(a.records, b.records);
});
test('different seeds produce different runs', () => {
const a = playThrough('seed-a', 40);
const b = playThrough('seed-b', 40);
assert.notDeepEqual(a.records.map((r) => r.eventId), b.records.map((r) => r.eventId));
});
test('a saved game resumes into exactly the run it left', () => {
const uninterrupted = playThrough('save', 30);
let state = startGame(testPack, { seed: 'save' });
for (let i = 0; i < 15; i++) {
const turn = currentTurn(testPack, state);
state = takeTurn(testPack, state, turn.options.find((o) => o.available).option.id).state;
}
// The whole save is one JSON round trip; nothing lives outside it.
state = JSON.parse(JSON.stringify(state));
for (let i = 0; i < 15; i++) {
const turn = currentTurn(testPack, state);
state = takeTurn(testPack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.deepEqual(state, uninterrupted.state);
});
test('a once-only event fires exactly once across a long run', () => {
const { records } = playThrough('once', 60);
assert.equal(records.filter((r) => r.eventId === 'first_day').length, 1);
assert.equal(records[0].eventId, 'first_day', 'its weight makes it the opener');
});
test('a locked option cannot be played', () => {
const state = startGame(testPack, { seed: 'locked' });
let probe = state;
// Walk to a turn that offers the gated option.
for (let i = 0; i < 10; i++) {
const turn = currentTurn(testPack, probe);
const charm = turn.options.find((o) => o.option.id === 'charm');
if (charm) {
assert.equal(charm.available, false);
assert.throws(() => takeTurn(testPack, probe, 'charm'), MoveError);
return;
}
probe = takeTurn(testPack, probe, turn.options.find((o) => o.available).option.id).state;
}
assert.fail('the gated option never came up');
});
test('an unknown option id is rejected', () => {
const state = startGame(testPack, { seed: 'bogus' });
assert.throws(() => takeTurn(testPack, state, 'no_such_option'), /has no option/);
});
test('a skill grows from choices and eventually unlocks its gate', () => {
const talkative = (options) => {
const practice = options.find((o) => o.option.id === 'practice' && o.available);
return (practice ?? options.find((o) => o.available)).option.id;
};
const { state } = playThrough('skill', 20, talkative);
assert.ok(state.skills.talking >= 3, `expected talking to grow, got ${state.skills.talking}`);
});
test('money going negative brings the hard-times branch into the pool', () => {
const spendy = (options) => {
const practice = options.find((o) => o.option.id === 'practice' && o.available);
return (practice ?? options.find((o) => o.available)).option.id;
};
let state = startGame(testPack, { seed: 'broke' });
let sawHardTimes = false;
for (let i = 0; i < 120; i++) {
const turn = currentTurn(testPack, state);
if (turn.event.id === 'hard_times') { sawHardTimes = true; break; }
state = takeTurn(testPack, state, spendy(turn.options, turn.event, state)).state;
}
assert.ok(state.money < 0 || sawHardTimes, 'the run should have gone broke');
assert.ok(sawHardTimes, 'hard times should surface once money is negative — the game never ends');
});
test('the turn record carries the diff and the options the player could not take', () => {
let state = startGame(testPack, { seed: 'record' });
state = takeTurn(testPack, state, currentTurn(testPack, state).options[0].option.id).state;
const turn = currentTurn(testPack, state);
const { record } = takeTurn(testPack, state, 'work');
assert.equal(record.eventId, turn.event.id);
assert.equal(record.optionId, 'work');
assert.deepEqual(record.changes.map((c) => c.path), ['money', 'relationships.chief.likes_you']);
assert.equal(record.changes[0].from + 10, record.changes[0].to);
const locked = record.offered.find((o) => o.id === 'charm');
assert.equal(locked.available, false);
assert.equal(locked.failed[0].path, 'skills.talking');
});
test('history accumulates every turn and is what the retrospective reads', () => {
const { state, records } = playThrough('history', 12);
assert.equal(state.history.length, 12);
assert.deepEqual(state.history, records);
});
test('a stat cannot be pushed past its declared bound', () => {
const { state } = playThrough('clamp', 80, (options) => {
const practice = options.find((o) => o.option.id === 'practice' && o.available);
return (practice ?? options.find((o) => o.available)).option.id;
});
assert.ok(state.skills.talking <= 10, 'talking is capped at 10');
assert.ok(state.relationships.pat.likes_you <= 100, 'relationship stats are capped at 100');
});
test('the run never ends on its own', () => {
const { state } = playThrough('endless', 200);
assert.equal(state.turn, 201);
assert.ok(currentTurn(testPack, state).options.some((o) => o.available), 'there is always something to do');
});
test('upkeep runs every turn and is recorded separately from the choice', () => {
const upkeepPack = structuredClone(testPack);
upkeepPack.upkeep = [
{ label: 'wage', effects: [{ path: 'money', op: 'add', value: 20 }] },
{
label: 'debt service',
requires: [{ path: 'flags.in_debt', op: '==', value: true }],
effects: [{ path: 'money', op: 'add', value: -5 }],
},
];
let state = startGame(upkeepPack, { seed: 'upkeep' });
const before = state.money;
const first = takeTurn(upkeepPack, state, currentTurn(upkeepPack, state).options[0].option.id);
assert.equal(first.record.upkeep.length, 1, 'the conditional entry is skipped while it does not apply');
assert.equal(first.record.upkeep[0].label, 'wage');
const fromChoice = first.record.changes
.filter((c) => c.path === 'money')
.reduce((sum, c) => sum + (c.to - c.from), 0);
assert.equal(first.state.money, before + fromChoice + 20);
// Once the flag is set, the conditional entry starts applying.
const nowIndebted = setPath(first.state, 'flags.in_debt', true);
const nextOption = currentTurn(upkeepPack, nowIndebted).options.find((o) => o.available).option.id;
const indebted = takeTurn(upkeepPack, nowIndebted, nextOption);
assert.deepEqual(indebted.record.upkeep.map((c) => c.label), ['wage', 'debt service']);
});
test('a character can start somewhere other than the pack default', () => {
const pack = structuredClone(testPack);
pack.characters.find((c) => c.id === 'pat').startingRelationship = { likes_you: 20 };
const state = startGame(pack, { seed: 'starts' });
assert.equal(state.relationships.pat.likes_you, 20, 'the override applies');
assert.equal(state.relationships.pat.fears_you, 50, 'unnamed stats keep the pack default');
assert.equal(state.relationships.chief.likes_you, 50, 'other characters are unaffected');
});
test('a pack with a calendar gets day-of-cycle state it can gate on', () => {
const dated = structuredClone(testPack);
dated.calendar = { cycleLength: 7, cycleName: 'Week', unitNames: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] };
let state = startGame(dated, { seed: 'calendar' });
assert.deepEqual(
{ index: state.calendar.index, cycle: state.calendar.cycle, name: state.calendar.name },
{ index: 0, cycle: 1, name: 'Mon' },
);
for (let i = 0; i < 7; i++) {
state = takeTurn(dated, state, currentTurn(dated, state).options.find((o) => o.available).option.id).state;
}
assert.equal(state.turn, 8);
assert.deepEqual(
{ index: state.calendar.index, cycle: state.calendar.cycle, name: state.calendar.name },
{ index: 0, cycle: 2, name: 'Mon' },
);
});
test('a pack with no calendar has none: the engine imposes no week', () => {
const state = startGame(testPack, { seed: 'no-calendar' });
assert.deepEqual(state.calendar, {});
});
test('scheduled upkeep falls due on its own period, not every turn', () => {
const scheduled = structuredClone(testPack);
scheduled.upkeep = [
{ label: 'daily', effects: [{ path: 'money', op: 'add', value: -1 }] },
{ label: 'weekly rent', every: 7, offset: 6, effects: [{ path: 'money', op: 'add', value: -50 }] },
{ label: 'fortnightly pay', every: 14, offset: 6, effects: [{ path: 'money', op: 'add', value: 200 }] },
];
let state = startGame(scheduled, { seed: 'schedule' });
const dueOn = [];
for (let turn = 1; turn <= 22; turn++) {
const result = takeTurn(scheduled, state, currentTurn(scheduled, state).options.find((o) => o.available).option.id);
dueOn.push(result.record.upkeep.map((c) => c.label));
state = result.state;
}
assert.deepEqual(dueOn[0], ['daily'], 'turn 1 is an ordinary day');
assert.deepEqual(dueOn[6], ['daily', 'weekly rent', 'fortnightly pay'], 'turn 7 is rent and payday');
assert.deepEqual(dueOn[13], ['daily', 'weekly rent'], 'turn 14 is rent only');
assert.deepEqual(dueOn[20], ['daily', 'weekly rent', 'fortnightly pay'], 'turn 21 is both again');
});
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getPath, setPath, hasPath, parsePath, listPaths } from '../src/engine/paths.js';
const state = { money: 100, skills: { talking: 2 }, relationships: { chief: { likes_you: 50 } } };
test('reads nested paths', () => {
assert.equal(getPath(state, 'money'), 100);
assert.equal(getPath(state, 'skills.talking'), 2);
assert.equal(getPath(state, 'relationships.chief.likes_you'), 50);
});
test('missing paths read as undefined, not as a throw', () => {
assert.equal(getPath(state, 'skills.welding'), undefined);
assert.equal(getPath(state, 'nothing.at.all'), undefined);
assert.equal(hasPath(state, 'skills.welding'), false);
assert.equal(hasPath(state, 'skills.talking'), true);
});
test('writes without mutating the original', () => {
const next = setPath(state, 'skills.talking', 5);
assert.equal(next.skills.talking, 5);
assert.equal(state.skills.talking, 2, 'original state must be untouched');
assert.equal(next.relationships, state.relationships, 'untouched branches are shared');
});
test('creates missing intermediate objects', () => {
const next = setPath({}, 'flags.deep.nested', true);
assert.equal(next.flags.deep.nested, true);
});
test('rejects prototype-polluting paths', () => {
assert.throws(() => parsePath('__proto__.polluted'), /forbidden/);
assert.throws(() => parsePath('constructor'), /forbidden/);
assert.throws(() => setPath({}, 'a.prototype.b', 1), /forbidden/);
});
test('rejects malformed paths', () => {
assert.throws(() => parsePath(''), /non-empty/);
assert.throws(() => parsePath('money.'), /invalid path segment/);
assert.throws(() => parsePath('a b'), /invalid path segment/);
assert.throws(() => parsePath(42), /non-empty string/);
});
test('lists leaf paths', () => {
assert.deepEqual(listPaths(state), ['money', 'skills.talking', 'relationships.chief.likes_you']);
});
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRng } from '../src/engine/rng.js';
test('the same seed produces the same sequence', () => {
const a = createRng('seed-one');
const b = createRng('seed-one');
const draws = Array.from({ length: 20 }, () => [a.next(), b.next()]);
for (const [x, y] of draws) assert.equal(x, y);
});
test('different seeds diverge', () => {
const a = Array.from({ length: 10 }, (_, i) => createRng('one').next());
const b = Array.from({ length: 10 }, (_, i) => createRng('two').next());
assert.notDeepEqual(a, b);
});
test('a generator resumes exactly from a saved position', () => {
const original = createRng('resume-me');
for (let i = 0; i < 7; i++) original.next();
const position = original.getPosition();
const resumed = createRng('resume-me', position);
const expected = Array.from({ length: 5 }, () => original.next());
const actual = Array.from({ length: 5 }, () => resumed.next());
assert.deepEqual(actual, expected);
});
test('a saved position survives a JSON round trip', () => {
const rng = createRng('json');
rng.next();
const revived = createRng('json', JSON.parse(JSON.stringify(rng.getPosition())));
assert.equal(revived.next(), createRng('json', rng.getPosition()).next());
});
test('draws stay in range', () => {
const rng = createRng('range');
for (let i = 0; i < 500; i++) {
const value = rng.next();
assert.ok(value >= 0 && value < 1);
assert.ok(rng.int(10) >= 0 && rng.int(10) < 10);
}
});
test('weighted picks respect weight and skip zero-weight entries', () => {
const rng = createRng('weighted');
const items = [{ id: 'common', w: 99 }, { id: 'rare', w: 1 }, { id: 'never', w: 0 }];
const counts = { common: 0, rare: 0, never: 0 };
for (let i = 0; i < 2000; i++) counts[rng.weighted(items, (i2) => i2.w).id]++;
assert.equal(counts.never, 0, 'zero weight must never be drawn');
assert.ok(counts.common > counts.rare * 10, `expected common to dominate, got ${JSON.stringify(counts)}`);
assert.ok(counts.rare > 0, 'a weight of 1 in 100 should still appear over 2000 draws');
});
test('weighted returns undefined when nothing has weight', () => {
const rng = createRng('empty');
assert.equal(rng.weighted([{ w: 0 }], (i) => i.w), undefined);
assert.equal(rng.weighted([], () => 1), undefined);
});
+80
View File
@@ -0,0 +1,80 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { eligibleEvents, selectEvent, resolveOptions } from '../src/engine/select.js';
import { createState, resolveSchema } from '../src/engine/state.js';
import { createRng } from '../src/engine/rng.js';
import { setPath } from '../src/engine/paths.js';
import { testPack } from './fixtures/test-pack.js';
const schema = resolveSchema(testPack);
const fresh = () => createState(testPack, { seed: 'select', schema });
test('a conditional event is out of the pool until its condition holds', () => {
const rich = fresh();
assert.ok(!eligibleEvents(testPack, rich).eligible.some((e) => e.id === 'hard_times'));
const broke = setPath(rich, 'money', -20);
assert.ok(eligibleEvents(testPack, broke).eligible.some((e) => e.id === 'hard_times'));
});
test('exclusions say why', () => {
const { excluded } = eligibleEvents(testPack, fresh());
const hardTimes = excluded.find((x) => x.event.id === 'hard_times');
assert.equal(hardTimes.reason, 'requires');
assert.equal(hardTimes.failed[0].path, 'money');
});
test('a once-only event leaves the pool after it fires', () => {
const state = setPath(fresh(), 'seen.first_day', 1);
const { eligible, excluded } = eligibleEvents(testPack, state);
assert.ok(!eligible.some((e) => e.id === 'first_day'));
assert.equal(excluded.find((x) => x.event.id === 'first_day').reason, 'once');
});
test('a cooldown holds an event back for the declared number of turns', () => {
let state = fresh();
state = setPath(state, 'lastSeen.audit', 5);
state = setPath(state, 'turn', 9);
assert.equal(eligibleEvents(testPack, state).excluded.find((x) => x.event.id === 'audit')?.reason, 'cooldown');
state = setPath(state, 'turn', 15);
assert.ok(eligibleEvents(testPack, state).eligible.some((e) => e.id === 'audit'));
});
test('events belonging to another stage are never drawn', () => {
const elsewhere = setPath(fresh(), 'stage', 'penthouse');
const { eligible, excluded } = eligibleEvents(testPack, elsewhere);
assert.equal(eligible.length, 0);
assert.ok(excluded.every((x) => x.reason === 'stage'));
});
test('selection is a weighted draw, so the heavy event dominates', () => {
const rng = createRng('draw');
const state = setPath(fresh(), 'seen.first_day', 1); // take the opener out of the way
const counts = {};
for (let i = 0; i < 1000; i++) {
const event = selectEvent(testPack, state, rng);
counts[event.id] = (counts[event.id] ?? 0) + 1;
}
assert.ok(counts.routine > 900, `routine (weight 100) should dominate audit (weight 5): ${JSON.stringify(counts)}`);
assert.ok(counts.audit > 0, 'a low weight is still reachable');
});
test('an option below its skill gate resolves as locked, with the reason', () => {
const event = testPack.events.find((e) => e.id === 'routine');
const resolved = resolveOptions(fresh(), event);
const charm = resolved.find((r) => r.option.id === 'charm');
assert.equal(charm.available, false);
assert.equal(charm.failed[0].path, 'skills.talking');
assert.equal(charm.hidden, false, 'locked options are shown greyed out unless the content says otherwise');
assert.equal(resolved.find((r) => r.option.id === 'work').available, true);
});
test('an option unlocks once the gate is met', () => {
const skilled = setPath(fresh(), 'skills.talking', 5);
const event = testPack.events.find((e) => e.id === 'routine');
assert.equal(resolveOptions(skilled, event).find((r) => r.option.id === 'charm').available, true);
});
+277
View File
@@ -0,0 +1,277 @@
// UI wiring tests.
//
// These drive the real app module against a fake DOM (test/fixtures/fake-dom.js).
// They prove the wiring runs — imports resolve, handlers fire, the screens
// render the right text, choices reach the engine, saves and the feedback log
// are written. They are not a substitute for playing it in a browser: layout,
// styling, file downloads and file imports are outside their reach.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { installFakeDom, byClass, firstByClass, byTag } from './fixtures/fake-dom.js';
/** Boot the app fresh against a clean fake DOM. */
async function boot() {
const dom = installFakeDom();
// A fresh module instance per test: the app holds module-level state.
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
bootApp(dom.root);
return dom;
}
const text = (root) => root.textContent;
const optionButtons = (root) => byClass(root, 'option');
const enabledOptions = (root) => optionButtons(root).filter((b) => !b.disabled);
test('the app boots to a playable turn screen', async () => {
const dom = await boot();
try {
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the turn screen rendered');
assert.match(text(dom.root), /Day 1/, 'the day counter is showing');
assert.match(text(dom.root), /Marlene Hobb/, 'the characters are listed');
assert.match(text(dom.root), /Trevor Pike/);
assert.ok(enabledOptions(dom.root).length > 0, 'there is something to click');
} finally {
dom.restore();
}
});
test('the header shows the pack\'s declared stats, and hides what it asks to hide', async () => {
const dom = await boot();
try {
const stats = text(firstByClass(dom.root, 'stats'));
assert.match(stats, /Money/);
assert.match(stats, /\$520/, 'money is formatted as money');
assert.match(stats, /Negotiation/);
assert.match(stats, /Standing/, 'reputation is renamed by the pack');
assert.doesNotMatch(stats, /Debt/, 'debt is hidden while it is zero');
assert.doesNotMatch(stats, /Notable/, 'hidden state stays out of the header');
} finally {
dom.restore();
}
});
test('choosing an option shows the outcome, the deltas and the upkeep', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
assert.ok(firstByClass(dom.root, 'outcome'), 'the outcome view rendered');
assert.match(text(dom.root), /What that changed/);
assert.match(text(dom.root), /And then the day happened/, 'upkeep is shown separately');
const upkeep = text(firstByClass(dom.root, 'outcome__upkeep'));
// Labelled by what caused it, not by the path it moved — this is the
// answer to "what was that daily drain for?".
assert.match(upkeep, /Coffee, transit, lunch/);
assert.doesNotMatch(upkeep, /Money/, 'the bare path name is not useful here');
assert.ok(byClass(dom.root, 'change').length > 0, 'individual changes are listed');
} finally {
dom.restore();
}
});
test('advancing moves to the next turn', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
assert.ok(firstByClass(dom.root, 'scenario'), 'back to choosing');
assert.match(text(dom.root), /Day 2/);
} finally {
dom.restore();
}
});
test('a locked option is shown, disabled, and says what it needs', async () => {
const dom = await boot();
try {
// Play until a locked option appears — the pack gates several.
let locked = null;
for (let i = 0; i < 40 && !locked; i++) {
locked = optionButtons(dom.root).find((b) => b.disabled);
if (locked) break;
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
assert.ok(locked, 'a gated option should turn up within 40 turns');
assert.match(locked.textContent, /Needs /, 'the requirement is spelled out');
locked.click(); // must be inert
assert.ok(firstByClass(dom.root, 'scenario'), 'clicking a locked option does nothing');
} finally {
dom.restore();
}
});
test('progress is written to local storage and restored on the next boot', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1'));
assert.equal(saved.state.turn, 3);
// Boot again against the same storage: the run should resume, not restart.
const { boot: bootAgain } = await import(`../src/ui/app.js?t=${Math.random()}`);
const secondRoot = dom.document.createElement('div');
bootAgain(secondRoot);
assert.match(text(secondRoot), /Day 3/, 'the saved run resumed');
} finally {
dom.restore();
}
});
test('every played turn is logged, separately from the save', async () => {
const dom = await boot();
try {
for (let i = 0; i < 3; i++) {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
const log = JSON.parse(dom.storage.getItem('theladder.feedback.v1'));
assert.equal(log.length, 3);
const entry = log[0];
assert.ok(entry.event_id && entry.action_taken, 'the choice is recorded');
assert.ok(Array.isArray(entry.changes) && entry.changes.length > 0, 'the diff is recorded');
assert.ok(Array.isArray(entry.offered), 'the options offered are recorded');
assert.equal(entry.rating, null, 'feedback starts empty');
const save = JSON.parse(dom.storage.getItem('theladder.save.v1'));
assert.equal(save.state.history.length, 3);
assert.ok(!JSON.stringify(save).includes('rating'), 'feedback is not mixed into the save');
} finally {
dom.restore();
}
});
test('the feedback widget is optional, and records a rating when used', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
const ratings = byClass(dom.root, 'rating');
assert.equal(ratings.length, 3, 'up, neutral and down');
ratings[0].click();
const log = JSON.parse(dom.storage.getItem('theladder.feedback.v1'));
assert.equal(log[0].rating, 'up');
// Clicking the same rating again clears it.
byClass(dom.root, 'rating')[0].click();
assert.equal(JSON.parse(dom.storage.getItem('theladder.feedback.v1'))[0].rating, null);
} finally {
dom.restore();
}
});
test('the comment fields write through without re-rendering under the cursor', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'feedback__toggle').click();
const fields = byTag(firstByClass(dom.root, 'feedback__detail'), 'textarea');
assert.equal(fields.length, 2, 'a comment box and a "what did you want to do" box');
fields[0].value = 'the wheel bit made me laugh';
fields[0].dispatch('input');
fields[1].value = 'I wanted to just hide in the stairwell';
fields[1].dispatch('input');
// The same element objects must still be live — a re-render would have
// replaced them and thrown the player out of the box they are typing in.
assert.equal(byTag(firstByClass(dom.root, 'feedback__detail'), 'textarea')[0], fields[0]);
// Written through on the way to the next turn.
firstByClass(dom.root, 'button--next').click();
const entry = JSON.parse(dom.storage.getItem('theladder.feedback.v1'))[0];
assert.equal(entry.comment, 'the wheel bit made me laugh');
assert.equal(entry.desired_but_unavailable_action, 'I wanted to just hide in the stairwell');
} finally {
dom.restore();
}
});
test('the retrospective summarises the run and is not an ending', async () => {
const dom = await boot();
try {
for (let i = 0; i < 6; i++) {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
firstByClass(dom.root, 'button').click(); // "Wrap up my career"
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
const body = text(dom.root);
assert.match(body, /Career retrospective/);
assert.match(body, /6 days in the mailroom/);
assert.match(body, /Where you stand with people/);
assert.match(body, /Marlene Hobb/);
assert.match(body, /Situations you saw/);
firstByClass(dom.root, 'button--primary').click(); // "Keep going"
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the player can carry on');
assert.match(text(dom.root), /Day 7/);
} finally {
dom.restore();
}
});
test('a run with nothing notable still gets a retrospective', async () => {
const dom = await boot();
try {
firstByClass(dom.root, 'button').click(); // wrap up on turn one
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
assert.match(text(dom.root), /Nothing that anybody would write down/);
} finally {
dom.restore();
}
});
test('the header names the day and counts the week', async () => {
const dom = await boot();
try {
const hud = text(firstByClass(dom.root, 'hud'));
assert.match(hud, /Monday/, 'the day is named');
assert.match(hud, /Week 1 · Day 1/, 'the week and the day number are both shown');
} finally {
dom.restore();
}
});
test('the diary says what is coming, so the drain is not a mystery', async () => {
const dom = await boot();
try {
const diary = text(firstByClass(dom.root, 'diary'));
assert.match(diary, /Wages in 4 days/, 'payday is announced before it lands');
assert.match(diary, /Rent in 7 days/);
assert.doesNotMatch(diary, /Loan service/, 'charges that do not apply are not listed');
} finally {
dom.restore();
}
});
test('wages and rent appear in the ledger on the days they fall due', async () => {
const dom = await boot();
try {
const ledgerOn = [];
for (let day = 1; day <= 8; day++) {
enabledOptions(dom.root)[0].click();
const upkeep = firstByClass(dom.root, 'outcome__upkeep');
ledgerOn.push(upkeep ? upkeep.textContent : '');
firstByClass(dom.root, 'button--next').click();
}
assert.match(ledgerOn[0], /Coffee, transit, lunch/, 'small change every day');
assert.doesNotMatch(ledgerOn[0], /Wages/, 'no wages on day 1');
assert.match(ledgerOn[4], /Wages/, 'paid on day 5, the first Friday');
assert.match(ledgerOn[7], /Rent/, 'rent on day 8, the second Monday');
} finally {
dom.restore();
}
});
+166
View File
@@ -0,0 +1,166 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { validatePack, assertValidPack } from '../src/engine/validate.js';
import { testPack } from './fixtures/test-pack.js';
/** A copy of the fixture with one thing broken. */
function broken(mutate) {
const pack = structuredClone(testPack);
mutate(pack);
return validatePack(pack);
}
const messages = (result) => result.errors.join('\n');
test('the fixture pack is valid and warning-free', () => {
const result = validatePack(testPack);
assert.deepEqual(result.errors, []);
assert.deepEqual(result.warnings, []);
});
test('an effect writing an undeclared path is caught', () => {
const result = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'skills.wleding', op: 'add', value: 1 });
});
assert.match(messages(result), /skills\.wleding.*not declared/s);
});
test('a relationship effect naming a character who does not exist is caught', () => {
const result = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'relationships.dave.likes_you', op: 'add', value: 1 });
});
assert.match(messages(result), /relationships\.dave\.likes_you.*not declared/s);
});
test('a condition reading an undeclared path is caught', () => {
const result = broken((pack) => {
pack.events[0].options[1].requires = [{ path: 'skills.charisma', op: '>=', value: 1 }];
});
assert.match(messages(result), /skills\.charisma.*not declared/s);
});
test('content may read engine bookkeeping but not write it', () => {
const readsTurn = broken((pack) => {
// Not the floor event — see the fallback rule below.
pack.events.find((e) => e.id === 'audit').requires = [{ path: 'turn', op: '>=', value: 3 }];
});
assert.deepEqual(readsTurn.errors, []);
const writesTurn = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'turn', op: 'add', value: 5 });
});
assert.match(messages(writesTurn), /engine-owned state/);
});
test('declaring a reserved root as pack state is caught', () => {
const result = broken((pack) => { pack.state['history'] = { initial: [] }; });
assert.match(messages(result), /reserved root/);
});
test('unknown operators are caught before they reach the engine', () => {
const badCondition = broken((pack) => {
pack.events[0].options[0].requires = [{ path: 'money', op: 'exceeds', value: 1 }];
});
assert.match(messages(badCondition), /unknown condition operator/);
const badEffect = broken((pack) => {
pack.events[0].options[0].effects[0].op = 'multiply';
});
assert.match(messages(badEffect), /unknown effect operation/);
});
test('add with a non-numeric value is caught', () => {
const result = broken((pack) => { pack.events[0].options[0].effects[0].value = 'ten'; });
assert.match(messages(result), /non-numeric/);
});
test('duplicate ids are caught', () => {
assert.match(messages(broken((pack) => { pack.events[1].id = 'routine'; })), /duplicate id/);
assert.match(messages(broken((pack) => { pack.characters[1].id = 'chief'; })), /duplicate id/);
assert.match(messages(broken((pack) => { pack.events[0].options[1].id = 'work'; })), /duplicate id/);
});
test('an id that cannot be a path segment is caught, because ids become state paths', () => {
const result = broken((pack) => { pack.events[0].id = 'routine day'; });
assert.match(messages(result), /must match/);
});
test('an event with no options is caught', () => {
const result = broken((pack) => { pack.events[0].options = []; });
assert.match(messages(result), /no options/);
});
test('a zero weight is caught, since the event could never fire', () => {
const result = broken((pack) => { pack.events[0].weight = 0; });
assert.match(messages(result), /greater than zero/);
});
test('a stage with no unconditional event is caught, since the pool can run dry', () => {
const result = broken((pack) => {
pack.events[0].requires = [{ path: 'money', op: '>', value: 0 }];
});
assert.match(messages(result), /no unconditional event/);
});
test('a starting stage with no events at all is caught', () => {
const result = broken((pack) => { pack.startingStage = 'penthouse'; });
assert.match(messages(result), /startingStage "penthouse" has no events/);
});
test('a character with no role_type warns but still plays', () => {
const result = broken((pack) => { delete pack.characters[0].role_type; });
assert.deepEqual(result.errors, []);
assert.match(result.warnings.join('\n'), /role_type/);
});
test('assertValidPack throws with every error listed', () => {
const pack = structuredClone(testPack);
pack.events[0].options[0].effects.push({ path: 'nope', op: 'add', value: 1 });
assert.throws(() => assertValidPack(pack), /is invalid/);
assert.doesNotThrow(() => assertValidPack(testPack));
});
test('upkeep is validated like any other effect list', () => {
const badPath = broken((pack) => {
pack.upkeep = [{ label: 'wage', effects: [{ path: 'monie', op: 'add', value: 20 }] }];
});
assert.match(messages(badPath), /monie.*not declared/s);
const empty = broken((pack) => { pack.upkeep = [{ label: 'wage', effects: [] }]; });
assert.match(messages(empty), /has no effects/);
const writesReserved = broken((pack) => {
pack.upkeep = [{ effects: [{ path: 'turn', op: 'add', value: 1 }] }];
});
assert.match(messages(writesReserved), /engine-owned state/);
});
test('a starting relationship for a stat that does not exist is caught', () => {
const result = broken((pack) => {
pack.characters[0].startingRelationship = { respects_you: 10 };
});
assert.match(messages(result), /unknown stat "respects_you"/);
});
test('a malformed calendar is caught', () => {
const badLength = broken((pack) => { pack.calendar = { cycleLength: 0 }; });
assert.match(messages(badLength), /cycleLength 0 must be a positive/);
const mismatched = broken((pack) => {
pack.calendar = { cycleLength: 7, unitNames: ['Mon', 'Tue'] };
});
assert.match(messages(mismatched), /unitNames has 2 entries but cycleLength is 7/);
});
test('a malformed upkeep schedule is caught', () => {
const badEvery = broken((pack) => {
pack.upkeep = [{ label: 'rent', every: 0, effects: [{ path: 'money', op: 'add', value: -1 }] }];
});
assert.match(messages(badEvery), /every 0 must be a positive/);
const orphanOffset = broken((pack) => {
pack.upkeep = [{ label: 'rent', offset: 3, effects: [{ path: 'money', op: 'add', value: -1 }] }];
});
assert.deepEqual(orphanOffset.errors, []);
assert.match(orphanOffset.warnings.join('\n'), /offset has no effect without every/);
});
+147
View File
@@ -0,0 +1,147 @@
// Build a single self-contained HTML file.
//
// Chrome and Firefox refuse ES module imports over file://, so the modular
// source that is pleasant to develop cannot be the thing you hand someone.
// This inlines the stylesheet and flattens the module graph into one script,
// producing a file that plays by double-clicking it.
//
// It is a deliberately small bundler that understands only the module syntax
// this project actually uses, and throws on anything it does not recognise —
// a silent mis-bundle would be far worse than a failed build.
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname, resolve, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const entryHtml = resolve(root, 'index.html');
const outDir = resolve(root, 'dist');
const outFile = resolve(outDir, 'theladder.html');
const id = (path) => relative(root, path).replaceAll('\\', '/');
/** Rewrite one module's source into a registry factory. */
function transform(source, path) {
const exports = [];
let body = source;
// import { a, b as c } from './x.js'
body = body.replace(
/^[ \t]*import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"];?[ \t]*$/gm,
(_m, names, from) => `const {${names.replaceAll(' as ', ': ')}} = __require(${JSON.stringify(id(resolve(dirname(path), from)))});`,
);
// import * as ns from './x.js'
body = body.replace(
/^[ \t]*import\s*\*\s*as\s+(\w+)\s+from\s*['"]([^'"]+)['"];?[ \t]*$/gm,
(_m, name, from) => `const ${name} = __require(${JSON.stringify(id(resolve(dirname(path), from)))});`,
);
// import name from './x.js'
body = body.replace(
/^[ \t]*import\s+(\w+)\s+from\s*['"]([^'"]+)['"];?[ \t]*$/gm,
(_m, name, from) => `const ${name} = __require(${JSON.stringify(id(resolve(dirname(path), from)))}).default;`,
);
// export { a, b } (no `from` — re-exports are not used in this project)
body = body.replace(/^[ \t]*export\s*\{([^}]*)\};?[ \t]*$/gm, (_m, names) => {
for (const entry of names.split(',').map((n) => n.trim()).filter(Boolean)) {
const [local, exported = local] = entry.split(/\s+as\s+/).map((n) => n.trim());
exports.push([exported, local]);
}
return '';
});
// export function / const / let / class
body = body.replace(/^[ \t]*export\s+(async\s+)?(function|const|let|class)\s+(\w+)/gm, (_m, async_, kind, name) => {
exports.push([name, name]);
return `${async_ ?? ''}${kind} ${name}`;
});
// export default <expression>
body = body.replace(/^[ \t]*export\s+default\s+/gm, () => {
exports.push(['default', '__default']);
return 'const __default = ';
});
const leftover = body.match(/^\s*(import|export)\s/m);
if (leftover) {
throw new Error(`${id(path)}: unsupported module syntax near "${leftover[0].trim()}"`);
}
const assignments = exports.map(([exported, local]) =>
` __exports[${JSON.stringify(exported)}] = ${local};`).join('\n');
return `__define(${JSON.stringify(id(path))}, (__exports, __require) => {\n${body}\n${assignments}\n});`;
}
async function collect(entryPath, seen = new Map()) {
if (seen.has(entryPath)) return seen;
const source = await readFile(entryPath, 'utf8');
seen.set(entryPath, null); // reserve the slot before recursing
const specifiers = [...source.matchAll(/^[ \t]*import\s.*?from\s*['"]([^'"]+)['"];?[ \t]*$/gm)]
.map((match) => match[1])
.filter((specifier) => specifier.startsWith('.'));
for (const specifier of specifiers) {
await collect(resolve(dirname(entryPath), specifier), seen);
}
seen.set(entryPath, transform(source, entryPath));
return seen;
}
async function build() {
const html = await readFile(entryHtml, 'utf8');
const styleMatch = html.match(/<link\s+rel="stylesheet"\s+href="([^"]+)"\s*\/?>/);
if (!styleMatch) throw new Error('index.html has no stylesheet link to inline');
const css = await readFile(resolve(root, styleMatch[1]), 'utf8');
const scriptMatch = html.match(/<script type="module">([\s\S]*?)<\/script>/);
if (!scriptMatch) throw new Error('index.html has no module script');
const bootstrapSource = scriptMatch[1];
const entrySpecifier = bootstrapSource.match(/from\s*['"]([^'"]+)['"]/)?.[1];
if (!entrySpecifier) throw new Error('could not find the entry module in index.html');
const entryPath = resolve(root, entrySpecifier);
const modules = await collect(entryPath);
const bootstrap = transform(bootstrapSource, entryHtml).replace(
/__define\("index\.html"/, '__define("__bootstrap__"',
);
const script = [
'(() => {',
' "use strict";',
' const __factories = new Map();',
' const __cache = new Map();',
' const __define = (id, factory) => __factories.set(id, factory);',
' const __require = (id) => {',
' if (__cache.has(id)) return __cache.get(id);',
' const factory = __factories.get(id);',
' if (!factory) throw new Error(`module not bundled: ${id}`);',
' const exports = {};',
' __cache.set(id, exports);',
' factory(exports, __require);',
' return exports;',
' };',
...[...modules.values()].map((code) => code.replace(/^/gm, ' ')),
bootstrap.replace(/^/gm, ' '),
' __require("__bootstrap__");',
'})();',
].join('\n');
const output = html
.replace(styleMatch[0], `<style>\n${css}\n </style>`)
.replace(scriptMatch[0], `<script>\n${script}\n </script>`);
await mkdir(outDir, { recursive: true });
await writeFile(outFile, output, 'utf8');
const kb = (Buffer.byteLength(output) / 1024).toFixed(1);
console.log(`built ${id(outFile)} — ${modules.size} modules, ${kb} kB`);
}
build().catch((error) => {
console.error(`build failed: ${error.message}`);
process.exitCode = 1;
});