Play PAPER WINDOW with the shipped pregens; fix four silent defects

Ran the starter end to end. It closed, and it found four defects in what I shipped an
hour earlier — all in the scenario data, all silent.

R-247: the Borrowed Authority did not work. I wrote scope "operate", which is not one
of the five scope tiers; an unrecognised scope ranks 0 and every action came back
"impossible" with no error. The first scene of the starter is the Borrowed Authority
tutorial. Fixed, guarded in check-kits, and covered by a test that documents the
failure mode.

R-248: the contact subsystem was unreachable in the scenario built around it. The case
file carried a standing for the Returned Clerks and no GROUP ITEM; resolveContact
returns null without one, so READ, SIGNAL and OFFER did nothing. Groups are data now,
and a test asserts every standing on a shipped case file has a group behind it.

R-249: legs wrote `kindId` where resolveLegDialog reads `leg.kind`. It worked only
because the terrain text happens to begin with "Extraction".

Not a defect: phase four went Difficult because Resources hit 0 and destitution makes
every remaining phase Difficult. Best beat in the session.

All four phases failed and the case still closed — Containment 8 to 2, Resources 8 to
0 — which is what the scenario is built to survive. Two of six closing requirements
succeeded and the settlement still completed. The Cancellation Man opened with a
critical against a raised riot shield and went through 10 points of armour, teaching
the shield and graded-defence lessons in one roll.

Also documented: nobody on the starter team can aim the return, because only the Anchor
Officer trains Transposition. The extraction page now says to narrate it rather than
roll six dice that will all fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-08-11 23:39:43 +01:00
co-authored by Claude Opus 5
parent 49c2e9fc7b
commit 4e28de2a64
118 changed files with 266 additions and 110 deletions
+24 -4
View File
@@ -555,6 +555,23 @@ await writePack("armour", ARMOURS.map(a => {
// The case file, already at the scenario's opening state.
const C = S.STARTER_CASE;
const caseId = idFor(`actor:${C.key}`);
// Group ITEMS from the scenario data. resolveContact looks these up and returns null
// without one, so a standing with no group silently switches off READ/SIGNAL/OFFER.
const groupDocs = S.STARTER_GROUPS.map(g => ({
_id: idFor(`owned:${C.key}:group_${g.groupId}`),
name: g.name, type: "group", img: ICON.actor,
system: {
description: g.description,
groupId: g.groupId, speciesId: "baseline", population: g.population,
npcTemplateId: g.npcTemplateId,
taboos: g.taboos, desires: g.desires,
correctOfferForm: g.correctOfferForm,
rewardsByStanding: g.rewardsByStanding,
standing: 0, impression: 0
},
effects: [], ...BASE()
}));
const caseDoc = {
_id: caseId, name: C.name, type: "expedition", img: ICON.caseFile ?? ICON.actor,
system: {
@@ -568,18 +585,21 @@ await writePack("armour", ARMOURS.map(a => {
destinationKnowledge: "briefed", currentLeg: 0, elapsedDays: 0,
siteTableId: "sites", hazardTableId: "hazards", returnClock: 0,
legs: C.legs.map((l, i) => ({
index: i, terrain: l.label, mode: "foot", kindId: l.kindId,
// `kind`, not `kindId` — resolveLegDialog reads leg.kind and falls back to a
// regex on the terrain text. Writing the wrong field worked only because
// "Extraction — closing the register" happens to start with the word.
index: i, terrain: l.label, mode: "foot", kind: l.kindId,
difficulty: l.difficulty, modifier: l.mod, days: 0,
explored: true, resolved: false, forcedEncounterId: "",
costSupply: 1, costPower: 0
}))
},
standings: [{ groupId: "returned_clerks", value: 0, impression: 0, encounters: 0,
knownTaboos: [], knownDesires: [], pidginEstablished: false, obligations: [] }],
standings: C.standings.map(id => ({ groupId: id, value: 0, impression: 0, encounters: 0,
knownTaboos: [], knownDesires: [], pidginEstablished: false, obligations: [] })),
across: false, crossingModifier: 0, salvageEra: "modern",
notes: C.notes
},
items: [], effects: [],
items: groupDocs, effects: [],
prototypeToken: { name: C.name, displayName: 20, actorLink: true, width: 1, height: 1,
texture: { src: ICON.actor }, sight: { enabled: false }, disposition: 1,
bar1: { attribute: "integrity" }, bar2: { attribute: "supply" }, flags: {} },
+67 -2
View File
@@ -27,12 +27,19 @@ import { strict as assert } from "node:assert";
import * as RULES from "../rules.mjs";
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS, CHARACTERISTIC_DICE }
from "../postings.mjs";
import { STARTER_AUTHORITY, STARTER_GROUPS, STARTER_CASE } from "./scenario-starter.mjs";
let passed = 0, failed = 0;
// Async tests are awaited in order rather than fired off — a failure that lands after
// the summary has printed is a failure nobody sees.
const pending = [];
const test = (name, fn) => {
try { fn(); passed++; }
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
pending.push(async () => {
try { await fn(); passed++; }
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
});
};
const runAll = async () => { for (const t of pending) await t(); };
/* ---------------------------------------------------------------- *
* Contact — R-246: a fumbled greeting used to cost nothing
@@ -174,8 +181,66 @@ test("a success against a critical still lands, one step down", () => {
test("a critical against a critical turns it aside", () =>
assert.equal(RULES.defenceOutcomeFor("critical", "critical").turnedAside, true));
/* ---------------------------------------------------------------- *
* Borrowed Authority — a bad scope fails silently, which is the worst way
* ---------------------------------------------------------------- */
test("an in-scope action is in scope", () =>
assert.equal(RULES.scopeStanceFor("routine", "routine"), "in-scope"));
test("one tier beyond is a stretch", () =>
assert.equal(RULES.scopeStanceFor("routine", "privileged"), "stretch"));
test("two tiers beyond is impossible at any skill", () =>
assert.equal(RULES.scopeStanceFor("routine", "administrative"), "impossible"));
// An unrecognised scope ranks 0, so EVERYTHING becomes impossible and nothing says so.
test("an unrecognised scope makes everything impossible", () =>
assert.equal(RULES.scopeStanceFor("operate", "routine"), "impossible"));
test("the starter's credential names a real scope tier", async () => {
assert.ok(RULES.SCOPE_TIERS.includes(STARTER_AUTHORITY.scope),
`scope "${STARTER_AUTHORITY.scope}" is not a tier`);
});
/* ---------------------------------------------------------------- *
* Shipped case files — a standing with no group is an unreachable subsystem
* ---------------------------------------------------------------- */
// resolveContact looks up a GROUP ITEM and returns null without one, so a case file
// carrying a standing with no matching group has READ, SIGNAL and OFFER silently
// switched off. That is how PAPER WINDOW first shipped, with the contact scene as its
// centrepiece and no error anywhere. Asserted against the scenario DATA rather than the
// built pack, because the pack is a LevelDB that Foundry holds open while it runs — and
// a test that cannot read its subject must fail loudly, not skip quietly.
test("every standing on the starter case file has a group behind it", () => {
const groups = new Set(STARTER_GROUPS.map(g => g.groupId));
for (const id of STARTER_CASE.standings ?? []) {
assert.ok(groups.has(id),
`case file has a standing for "${id}" with no group — READ/SIGNAL/OFFER cannot run`);
}
});
// resolveLegDialog reads `leg.kind`. A case file that writes `kindId` falls through to
// a regex on the terrain prose, which works right up until somebody renames a phase.
test("starter phases name a kind the engine will read", () => {
const kinds = ["approach", "cordon", "containment", "extraction"];
for (const l of STARTER_CASE.legs) {
assert.ok(kinds.includes(l.kindId), `phase "${l.label}" has kind "${l.kindId}"`);
}
assert.equal(STARTER_CASE.legs.at(-1).kindId, "extraction",
"the last phase must be the extraction, or the return clock never moves");
});
test("every starter group is usable by the contact engine", () => {
for (const g of STARTER_GROUPS) {
assert.ok(g.groupId && g.name, "a group needs an id and a name");
assert.ok(g.desires?.length, `${g.groupId}: a group with no desire cannot be offered anything`);
assert.ok(g.correctOfferForm, `${g.groupId}: no correct offer form`);
for (const t of g.taboos ?? []) {
assert.ok(t.id && t.text, `${g.groupId}: a taboo needs an id and text`);
}
}
});
/* ---------------------------------------------------------------- */
await runAll();
if (failed) {
console.error(`check-behaviour: ${failed} failure(s), ${passed} passed`);
process.exit(1);
+14 -1
View File
@@ -9,7 +9,7 @@
* node tools/check-kits.mjs
*/
import { SKILL_CATALOGUE, TALENTS, GEAR, WEAPONS, ARMOURS, DEVICES } from "./content.mjs";
import { SHIELD } from "../rules.mjs";
import { SHIELD, SCOPE_TIERS } from "../rules.mjs";
// Imported, not regexed out of the engine source. See postings.mjs.
import { ROLES, TRADES, INDUCTION, CHARACTERISTIC_DICE } from "../postings.mjs";
@@ -64,6 +64,19 @@ for (const [id, r] of Object.entries({ ...ROLES, ...TRADES })) {
kitSets.set(sig, id);
}
// A Borrowed Authority whose scope is not a real tier reads as rank 0 — "nothing" —
// and every single action through it comes back "impossible" with no error anywhere.
// The starter scenario shipped with scope "operate", which is not a tier, and the only
// symptom was a credential that silently did not work.
{
const S = await import("./scenario-starter.mjs").catch(() => null);
const auth = S?.STARTER_AUTHORITY;
if (auth && !SCOPE_TIERS.includes(auth.scope)) {
problems.push(`starter authority "${auth.name}": scope "${auth.scope}" is not a `
+ `scope tier (have: ${SCOPE_TIERS.join(", ")})`);
}
}
// INDUCTION is handed to every trade character, so its keys are checked once here
// rather than ten times above.
for (const k of INDUCTION.skills) {
+62 -4
View File
@@ -351,12 +351,21 @@ export const STARTER_PAGES = [
},
{ name: "10 · Extraction and debrief", content:
P("Use the normal return procedure and Transposition rolls in every ending. A clean "
+ "settlement leaves the window clock at zero and adds no case-specific penalty.")
+ UL(["Another agent may aim the return for someone else.",
P("Use the normal return procedure in every ending. A clean settlement leaves the "
+ "window clock at zero and adds no case-specific penalty.")
+ UL(["Another agent may aim the return for someone else — the team's best Transposition.",
"Failure gets an agent home <em>near</em> the target; it never strands them.",
"A fumble costs Coherence and may grant a Quiet Art.",
"PAPER WINDOW is Near: no classification penalty."])
+ P("<strong>This team has no Anchor Officer, and that is deliberate.</strong> "
+ "Transposition is trained by exactly one posting in the register, and none of "
+ "these six has it: every one of them aims the return at the printed 1%. "
+ "<em>Do not roll six dice to discover that.</em> Narrate it: they all get home, "
+ "and none of them gets home neatly — wrong street, wrong hour, one of them in the "
+ "wrong coat. Roll only if an agent has a reason to want a clean arrival, or if the "
+ "window clock has moved and you want the fumble on the table.")
+ P("It is worth saying out loud at the debrief that the department sent six people to "
+ "a crossing and no anchor. That is the campaign, not an oversight.")
+ P("When everyone is home, close the case and resolve experience.")
+ H(2, "Debrief — ask each player one")
+ OL(["<strong>Field Lead:</strong> at what moment did this stop being the case in your orders?",
@@ -660,7 +669,7 @@ export const STARTER_AUTHORITY = {
holderName: "Imogen Price",
holderRole: "Senior Asbestos Surveyor",
issuer: "Halcyon Environmental Compliance Ltd",
scope: "operate",
scope: "routine",
scopeNote: "Routine site inspection at a controlled demolition site.",
trust: 2,
redFlags: [
@@ -676,6 +685,54 @@ export const STARTER_AUTHORITY = {
+ "<p><strong>A fumble burns it:</strong> the real Imogen Price telephones the site manager.</p>"
};
/**
* The groups the team can talk to, as DATA rather than as something the pack builder
* invents. `resolveContact` looks up a group ITEM on the case file and returns null
* without one — a standing record alone leaves READ, SIGNAL and OFFER silently switched
* off, which is how this scenario first shipped, with the contact scene as its
* centrepiece. The pairing is asserted in check-behaviour against this file.
*/
export const STARTER_GROUPS = [{
groupId: "returned_clerks",
name: "The Returned Clerks",
population: 6,
npcTemplateId: "pw_evelyn",
description:
"<p>Six clerks in 1987 uniforms under green lamps, none of them aged. They have been "
+ "on the same shift for thirty-nine years, and they are not invaders — they are "
+ "waiting to be relieved.</p>",
taboos: [
{ id: "sealed_mail",
text: "Never open, read aloud or name the addressee of mail that is not yours. A sealed identity belongs to its recipient.",
penaltyIfViolated: -30 },
{ id: "the_counter",
text: "The public side of the counter is the public side. Speaking from behind it is not rudeness — it is not speaking at all.",
penaltyIfViolated: -20 }
],
desires: [
{ id: "relief",
text: "To be relieved. A recognised authority to receipt the disputed folio so the shift can close.",
offerBonusStep: "easy" }
],
correctOfferForm:
"A signed receipt taking the disputed folio into recognised custody, passed through "
+ "Window Six from the public side of the counter, quoting case R-143.",
rewardsByStanding: {
"1": [{ id: "pw1_explain",
text: "Evelyn explains that the register needs custody and certification, and what that means.",
effect: { type: "reveal", note: "The folio needs a custodian, a medical certification that the duplicates are living persons, and a current date stamp." } }],
"2": [{ id: "pw2_folio",
text: "Evelyn explains the clean closure and will release the folio once the six requirements are met.",
effect: { type: "reveal", note: "R-143-A: a pending inter-jurisdictional register. Six signatures, one per agent." } },
{ id: "pw2_stamp",
text: "She says which drawer holds the crown master stamp, and that it opens from the public side.",
effect: { type: "skillBonus", skillFamilyId: "sleight_of_hand", value: 20, phases: [3] } }],
"3": [{ id: "pw3_help",
text: "The night shift helps. Two clerks hold the folio steady while the engineer works.",
effect: { type: "waiveCost", phase: 3 } }]
}
}];
/** The case file, already set to the scenario's opening state. */
export const STARTER_CASE = {
key: "pw_case",
@@ -687,6 +744,7 @@ export const STARTER_CASE = {
+ "minutes of argument without action, a loud weapon fired in the mundane building, the "
+ "credential burned, or the clerks' taboo violated after it has been learned.</p>"
+ "<p>Not for careful investigation. Never for careful investigation.</p>",
standings: ["returned_clerks"],
legs: [
{ label: "Approach — the demolition cordon", kindId: "approach", difficulty: "average", mod: 0 },
{ label: "Investigation — the dead-letter cage", kindId: "cordon", difficulty: "average", mod: -5 },