v0.8.0.7 — the Salvage Yard had nothing to say, and phases too little time to read

The Salvage Yard was face up all along; its tile just read "a card". apply.ts
pushes a synthetic train-<n> id on trainScheduled, nothing in s.cards matches it,
and cardName fell through to its default — and since a train is scheduled several
times a Day that id is on top most of the time. Measured before touching anything:
the tile read "a card" from the opening frame through 60 pushes while its depth
climbed from 2 to 8. cardName resolves it now, in sim/view.ts, because this is a
name.

The engine half is filed as Gitea#23 rather than fixed here. reshuffleIfDepleted
sweeps the Salvage Yard back into the draw deck, so that synthetic id can be
shuffled in and drawn into a hand as an id with no card behind it. Eight games
driven to 4000 moves across eight seeds produced zero reshuffles, so it is latent;
there are two defensible fixes and the choice turns on what the synthetic id is
for, which is not a call to make while fixing a label.

And phases scale with the speed control again, damped to a third of the rate. They
were pinned in v0.8.0.3 because scaling them walled off a player's own turn; pinned
turns out to be too short to read at 10x. Damped satisfies both: 1x unchanged, 10x
lands exactly on the four-times guess. Bounded because phase beats cluster rather
than accumulate — 1.0 per push on average, 4 at worst, so the wait after a move is
~2.4s typical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
This commit is contained in:
Jesse.Markowitz
2026-09-10 06:58:09 -04:00
co-authored by Claude Opus 5
parent 64e8ce584f
commit d0e5091824
6 changed files with 142 additions and 12 deletions
+28 -10
View File
@@ -170,23 +170,41 @@ describe('pacing — dwell by kind', () => {
);
});
it('the speed control stretches other people, not the clock', () => {
it('the speed control stretches the clock at a THIRD of the rate it stretches people', () => {
/**
* Jesse, from a real 5× game: *"after my turn, when I actually execute my turn, I'm still subject
* to that same delay before it moves on. That makes no sense."* It was not his move being
* replayed — it was the automatic phases behind it, which were scaling with `pace` along with
* everything else. Measured over 40 turns, the waiting split almost evenly between other players
* and phases turning over, so a 5× game spent 105 seconds on the clock alone.
* Two complaints, one from each direction, and the answer is between them.
*
* v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
* moves on. That makes no sense."* — phases were scaling with everything else and walling off a
* player's own turn. So they were pinned at their tabled beat.
*
* v0.8.0.7, from a 10× game: *"phases displayed on the upper line go by too quickly still.
* Should be 4 times as long — at a guess."* — pinned was too short to read the caption.
*
* Damped scaling satisfies both: 1× unchanged, 10× lands exactly on the four-times guess, and
* the cost stays bounded because phase beats cluster rather than accumulate.
*/
const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } };
const theirs = { cause: 'switch.move' as const, player: 1, lines: ['moved'], frame: { table: {} } };
for (const pace of [1, 3, 5, 7]) {
assert.equal(dwellForStep(phase, pace), DWELL.phase, `a phase beat grew at ${pace}x`);
assert.equal(dwellForStep(theirs, pace), DWELL.switching * pace);
assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
assert.equal(dwellForStep(phase, 10), DWELL.phase * 4, '10x must be four times it, as asked for');
for (const pace of [2, 3, 5, 7, 10, 15, 20]) {
const p = dwellForStep(phase, pace);
const t = dwellForStep(theirs, pace);
assert.ok(p > DWELL.phase, `a phase must grow at ${pace}x`);
assert.ok(
p < DWELL.phase * pace,
`a phase must grow SLOWER than the multiplier at ${pace}x, or the clock walls off the turn`,
);
assert.ok(t > p, `somebody's move must still outlast a phase beat at ${pace}x`);
}
// Off still means off, for the clock as much as for anybody's move.
// Off still means off, for the clock as much as for anybody's move; and below 1x the clock
// follows the multiplier straight, because "faster" should mean everything.
assert.equal(dwellForStep(phase, 0), 0);
assert.equal(dwellForStep(theirs, 0), 0);
assert.equal(dwellForStep(phase, 0.5), DWELL.phase * 0.5);
});
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
+28
View File
@@ -3647,6 +3647,34 @@ describe('the Day rolling over says so (Gitea#10)', () => {
);
});
it('names the train on top of the Salvage Yard, instead of "a card"', () => {
/**
* The Salvage Yard is a FACE-UP pile and its tile reads the top card — but `trainScheduled`
* pushes a synthetic `train-<number>` id rather than the id of the card that was played
* (`apply.ts`), so there was nothing in `s.cards` to look up and the tile said "a card". A train
* is scheduled several times a Day, so that id is on top most of the time: the pile was face up
* and had nothing to say. Jesse, 2026-09-10: *"why is salvage deck not face up. I should see the
* card played onto salvage."*
*/
const s = createEngineGame({
id: 'salv',
seed: 5,
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 60,
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
});
s.decks.salvageYard.push('train-13');
const f = snapshot(s, [], null);
assert.equal(f.salvage.top, 'Train 13', 'a scheduled train on the pile must be named');
const html = pilesHtml(f);
assert.ok(html.includes('Train 13'), `the Salvage tile does not name the train:\n${html}`);
assert.equal(html.includes('>a card<'), false, 'the face-up pile still says "a card"');
});
it('lights only the pile a watched move touched', () => {
const s = createEngineGame({
id: 'piles2',