various fixes. added version#s and added a playable browser build of the solitaire game (including deploy to filebrowser script)

This commit is contained in:
Jesse
2026-07-31 22:03:13 -04:00
parent 160190da3f
commit 2eca9de09f
16 changed files with 2399 additions and 401 deletions
+87 -4
View File
@@ -27,12 +27,14 @@ import {
modifierProfile,
nextOfficeTier,
officeProfile,
trainProfile,
} from './content.ts';
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
import type { GameEvent } from './events.ts';
import type { Intent, RejectionCode } from './intents.ts';
import type {
CardId,
CrewTray,
Facility,
GameState,
GridCoord,
@@ -468,7 +470,15 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
return 'NO_SUITABLE_CAR';
}
return null;
// §8.2 — "the train must be in the order listed on the train's card (engine on the front,
// Rolling Stock, and possibly a Caboose). It may depart with FEWER Rolling Stock than listed,
// but not out of order."
//
// This was not enforced at all: any car could be added in any quantity, so Train 9 "Heavy
// Freight" — a card calling for 3 freight AND a caboose — was made up with four hoppers and
// no caboose. Fewer is allowed; more, or of the wrong category, is not.
return acceptsCar(tray, i.carType) ? null : 'NO_SUITABLE_CAR';
}
case 'newTrain.secondSection': {
@@ -613,8 +623,15 @@ function checkPlay(
// nothing, which is exactly the bug that made Modifiers dead weight for weeks.
return 'NOT_IMPLEMENTED';
case 'timetabledTrain':
case 'extraTrain':
// A train card goes to the TIMETABLE, not onto the board. Accepting a placement made the UI
// offer the same play at six different grid squares with six rotations apiece — all identical,
// because the placement was then ignored. Same reasoning as the Office upgrade above: silently
// discarding a placement makes the event log claim the card was laid somewhere it was not.
return placement ? 'NO_PLACEMENT' : null;
default:
// Train cards go to the timetable; the phase driver schedules them.
return null;
}
}
@@ -1528,10 +1545,76 @@ function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKin
}
/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */
/**
* §2.1 Gap 4a — "when you extend your Running Track, the Limits sign MOVES outwards with it".
*
* The sign is a physical card, not just a recorded column. Moving only the coordinate left the
* Limits card stranded mid-track: a district grew to
* (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2)=Freight House …
* with everything beyond (0,-2) built OUTSIDE a sign that never moved. That is not cosmetic —
* §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds
* an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault.
*/
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
if (placed.row !== area.runningRow) return;
if (placed.col <= area.limitsWest.col) area.limitsWest = { row: placed.row, col: placed.col - 1 };
if (placed.col >= area.limitsEast.col) area.limitsEast = { row: placed.row, col: placed.col + 1 };
if (placed.col <= area.limitsWest.col) {
area.limitsWest = { row: placed.row, col: placed.col - 1 };
area.grid.set(coordKey(area.limitsWest), limitsCard());
}
if (placed.col >= area.limitsEast.col) {
area.limitsEast = { row: placed.row, col: placed.col + 1 };
area.grid.set(coordKey(area.limitsEast), limitsCard());
}
}
/**
* §8.2 — may this car be coupled onto this train right now?
*
* "The train must be in the order listed on the train's card (engine on the front, Rolling Stock,
* and possibly a Caboose). It may depart with FEWER Rolling Stock than listed, but not out of
* order." Fewer is allowed; more, or of the wrong category, is not.
*
* SHARED. Both `check` and the New Train Phase's "is there a suitable car in the yard" test call
* this. A second copy stalled the game outright: the phase believed a car could be added while
* `check` rejected every option, so the Stage never completed.
*/
export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
if (!profile) return true;
const cat = (t: CarType): 'coach' | 'caboose' | 'freight' =>
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
const adding = cat(carType);
const already = tray.consist.filter((c) => cat(c.type) === adding).length;
const allowed =
adding === 'coach'
? profile.consist.coach
: adding === 'caboose'
? profile.consist.caboose
: profile.consist.freight;
if (already >= allowed) return false;
// The caboose rides last (§A.3), so nothing may be coupled behind one.
if (adding !== 'caboose' && tray.consist.some((c) => c.type === 'caboose')) return false;
// The card also narrows WHICH freight types it will take.
const types = profile.consist.freightTypes;
if (adding === 'freight' && types && !types.includes(carType)) return false;
return true;
}
/** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */
function limitsCard(): TrackCard {
return {
geometry: { kind: 'limits' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
}
// ---------------------------------------------------------------------------