Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e482f07157 | ||
|
|
1d4ac0fdbd | ||
|
|
cc59b81155 |
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+62
@@ -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');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '-');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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.'),
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Vendored
+162
@@ -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 };
|
||||
Vendored
+87
@@ -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)];
|
||||
Vendored
+112
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user