Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/game-design/player-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ Current shipped behavior for interactive mode. Concept and future-design notes l
live drag-banana physics. Winning still grants the Circuit.
- Ridge conversation uses `advance` / `choose`. `pnpm ridge:console` is an alias
that starts already inside Ridge.
- Ridge first-playable secret route: Bridge → Concert → Dance Festival → Relay
farewell (Sit and Play, dedication card), then return to the Overworld street.
Progress resets so the next CRT entry starts Bridge again.
- One-shot scripts: `pnpm game:console --script "look; go right 3; interact" --json`.

## Runtime Notes For Playtesting
Expand Down
38 changes: 35 additions & 3 deletions docs/game-design/ridge/ridge-snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,46 @@ Current runtime characteristics:
## Active Runtime Route Read

```text
Nature / hill entry
Bridge
Nature / hill entry
-> Cicka + toy car play spot (optional Persona-style choice)
-> blocked bridge + unfinished blueprint
-> Bridge Draftsperson
-> Cicka Parallel Play (receive toy car)
-> toy-car bridge test
-> completed crossing
-> Bridge-to-Concert handoff

Concert
-> crowd / traffic crossing blocker + night facades
-> musician-side nook (hidden Cicka + Injured Guitarist)
-> forgiving practice riff
-> auto-success concert (crowd clears, guitar received)
-> Dance transition exit

Dance Festival
-> Last-Stop Plaza setup + service-road blocker
-> traveler wayfinding + triangulated resident reads
-> Operations Handoff Check + One-Step Practice
-> Folded Song Request
-> Setup Clearance montage
-> last daylight shuttle -> Relay

Relay Ending
-> tiny sunset overlook linger
-> Sit and Play beside Cicka
-> Route Memory Montage (3 flashes) + Let the song end
-> Cicka Threshold Farewell + Dedication Card
-> return to Overworld (CRT story ends; Circuit kept)
```

Concert, Dance Festival, Relay, and the ending sequence remain future
implementation slices. Copy the Bridge console-content + stick-visual pattern.
After the dedication, v0 returns to the Overworld street rather than an in-Ridge
Bridge replay. Route progress resets so the next CRT entry starts Bridge fresh.
Long-term Open Ridge Return / post-game can replace this eject later.

Full route is playable in `pnpm ridge:console` and covered by
`src/game/core/ridge/session.test.ts`. Stick visuals swap backgrounds/cast per
area via `StickVisualProvider`.

## Console Contract (for AI agents)

Expand All @@ -94,8 +122,12 @@ Useful commands:
| `advance` | Continue halted conversation |
| `choose <n\|id>` | Persona-style reply |
| `leave` | Exit conversation early |
| `skip` / `next` | Playtest: jump to next area start |
| `warp bridge\|concert\|dance\|relay` | Playtest: warp to an area |
| `help` | Command list |

Browser DEV only: `]` skips to the next area; `1`–`4` warp Bridge / Concert / Dance / Relay.

Observation JSON (`--json`) includes `nearby[].distance`, actors, inventory,
conversation state, and hints.

Expand Down
17 changes: 6 additions & 11 deletions scripts/game-console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,14 @@ import {
type GameSceneId,
formatGameObservation
} from '../src/game/core/console/index';
import type { BridgeDialogueCatalog } from '../src/game/core/ridge/index';
import {
loadRouteCatalogFromMessages,
type RidgeRouteDialogueCatalog
} from '../src/game/core/ridge/index';
import { enMessages } from '../src/shared/i18n/messages/en';

function loadCatalog(): BridgeDialogueCatalog {
const bridge = enMessages.scenes.ridge.bridge;
return {
speakers: {
prompt: bridge.speakers.prompt,
cicka: bridge.speakers.cicka,
bridgeDraftsperson: bridge.speakers.bridgeDraftsperson
},
lines: { ...bridge.dialogue }
};
function loadCatalog(): RidgeRouteDialogueCatalog {
return loadRouteCatalogFromMessages(enMessages.scenes.ridge);
}

function parseScene(args: string[]): GameSceneId {
Expand Down
18 changes: 9 additions & 9 deletions src/game/bridge/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ describe('bridgeStore', () => {
shortcutIds: [],
firstPlayableRoute: {
activeAreaId: 'bridge',
bridgeBeat: 'intro'
beat: 'intro'
}
});
expect(bridgeStore.getState().inventory.ownedItemIds).toEqual([]);
Expand Down Expand Up @@ -204,7 +204,7 @@ describe('bridgeStore', () => {
shortcutIds: [],
firstPlayableRoute: {
activeAreaId: 'bridge',
bridgeBeat: 'intro'
beat: 'intro'
}
});
expect(bridgeStore.getState().inventory.ownedItemIds).toEqual([]);
Expand Down Expand Up @@ -272,25 +272,25 @@ describe('bridgeStore', () => {
it('tracks the first playable Ridge route handoff separately from rewards', () => {
expect(bridgeStore.getState().progress.ridge.firstPlayableRoute).toEqual({
activeAreaId: 'bridge',
bridgeBeat: 'intro'
beat: 'intro'
});

bridgeActions.setRidgeBridgeBeat('needs_toy_car');
bridgeActions.setRidgeRouteBeat('needs_toy_car');
expect(bridgeStore.getState().progress.ridge.firstPlayableRoute).toEqual({
activeAreaId: 'bridge',
bridgeBeat: 'needs_toy_car'
beat: 'needs_toy_car'
});

bridgeActions.triggerRidgeConcertHandoff();
bridgeActions.setRidgeAreaHandoff('concert', 'concert_arrival');
expect(bridgeStore.getState().progress.ridge.firstPlayableRoute).toEqual({
activeAreaId: 'concert',
bridgeBeat: 'concert_handoff'
beat: 'concert_arrival'
});

bridgeActions.resetProgress();
bridgeActions.resetRidgeFirstPlayableRoute();
expect(bridgeStore.getState().progress.ridge.firstPlayableRoute).toEqual({
activeAreaId: 'bridge',
bridgeBeat: 'intro'
beat: 'intro'
});
});

Expand Down
70 changes: 49 additions & 21 deletions src/game/bridge/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,24 +48,33 @@ export type RidgeStampId = string;
export type RidgeManualPageId = string;
export type RidgeShortcutId = string;
export type RidgeFirstPlayableAreaId = 'bridge' | 'concert' | 'danceFestival' | 'relay';
export type RidgeBridgeBeatState =
export type RidgeRouteBeatState =
| 'intro'
| 'needs_toy_car'
| 'toy_car_shared'
| 'bridge_complete'
| 'concert_handoff';
export type RidgeBridgeAreaBeatState = Exclude<RidgeBridgeBeatState, 'concert_handoff'>;
| 'concert_arrival'
| 'concert_practiced'
| 'concert_cleared'
| 'dance_arrival'
| 'dance_ready'
| 'dance_cleared'
| 'relay_linger'
| 'relay_farewell'
| 'relay_complete';
/** @deprecated Prefer RidgeRouteBeatState */
export type RidgeBridgeBeatState = RidgeRouteBeatState;
/** @deprecated Prefer RidgeRouteBeatState */
export type RidgeBridgeAreaBeatState = Extract<
RidgeRouteBeatState,
'intro' | 'needs_toy_car' | 'toy_car_shared' | 'bridge_complete'
>;
export type RidgePostBridgeAreaId = Exclude<RidgeFirstPlayableAreaId, 'bridge'>;

export type RidgeFirstPlayableRouteState =
| {
activeAreaId: 'bridge';
bridgeBeat: RidgeBridgeAreaBeatState;
}
| {
activeAreaId: RidgePostBridgeAreaId;
bridgeBeat: 'concert_handoff';
};
export type RidgeFirstPlayableRouteState = {
activeAreaId: RidgeFirstPlayableAreaId;
beat: RidgeRouteBeatState;
};

export interface BridgeRidgeProgressState {
stampIds: RidgeStampId[];
Expand Down Expand Up @@ -146,7 +155,7 @@ function createInitialRidgeProgress(): BridgeRidgeProgressState {
function createInitialRidgeFirstPlayableRoute(): RidgeFirstPlayableRouteState {
return {
activeAreaId: 'bridge',
bridgeBeat: 'intro'
beat: 'intro'
};
}

Expand Down Expand Up @@ -302,8 +311,8 @@ function isBridgeProgressUnchanged(previous: BridgeState, candidate: BridgeState
arraysEqual(previous.progress.ridge.shortcutIds, candidate.progress.ridge.shortcutIds) &&
previous.progress.ridge.firstPlayableRoute.activeAreaId ===
candidate.progress.ridge.firstPlayableRoute.activeAreaId &&
previous.progress.ridge.firstPlayableRoute.bridgeBeat ===
candidate.progress.ridge.firstPlayableRoute.bridgeBeat
previous.progress.ridge.firstPlayableRoute.beat ===
candidate.progress.ridge.firstPlayableRoute.beat
);
}

Expand Down Expand Up @@ -523,7 +532,7 @@ export const bridgeActions = {
};
});
},
setRidgeBridgeBeat(bridgeBeat: RidgeBridgeAreaBeatState): void {
setRidgeRouteBeat(beat: RidgeRouteBeatState): void {
setState((current) => ({
...current,
progress: {
Expand All @@ -532,28 +541,47 @@ export const bridgeActions = {
...current.progress.ridge,
firstPlayableRoute: {
...current.progress.ridge.firstPlayableRoute,
activeAreaId: 'bridge',
bridgeBeat
beat
}
}
}
}));
},
triggerRidgeConcertHandoff(): void {
/** @deprecated Prefer setRidgeRouteBeat */
setRidgeBridgeBeat(bridgeBeat: RidgeBridgeAreaBeatState): void {
bridgeActions.setRidgeRouteBeat(bridgeBeat);
},
setRidgeAreaHandoff(areaId: RidgeFirstPlayableAreaId, beat: RidgeRouteBeatState): void {
setState((current) => ({
...current,
progress: {
...current.progress,
ridge: {
...current.progress.ridge,
firstPlayableRoute: {
activeAreaId: 'concert',
bridgeBeat: 'concert_handoff'
activeAreaId: areaId,
beat
}
}
}
}));
},
resetRidgeFirstPlayableRoute(): void {
setState((current) => ({
...current,
progress: {
...current.progress,
ridge: {
...current.progress.ridge,
firstPlayableRoute: createInitialRidgeFirstPlayableRoute()
}
}
}));
},
/** @deprecated Prefer setRidgeAreaHandoff('concert', 'concert_arrival') */
triggerRidgeConcertHandoff(): void {
bridgeActions.setRidgeAreaHandoff('concert', 'concert_arrival');
},
resetProgress(): void {
setState((current) => ({
...current,
Expand Down
13 changes: 12 additions & 1 deletion src/game/core/console/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ const SIMPLE_COMMANDS: Record<string, GameCommand> = {
continue: { type: 'advance' },
start: { type: 'start' },
fight: { type: 'fight' },
clear: { type: 'fight' }
clear: { type: 'fight' },
skip: { type: 'skip' },
next: { type: 'skip' }
};

const GO_ALIASES = new Set(['go', 'walk', 'move']);
Expand All @@ -41,6 +43,10 @@ export function getGameHelpText(): string {
'Ridge conversation:',
' advance | choose <id|index>',
'',
'Ridge playtest skips:',
' skip next area',
' warp bridge|concert|dance|relay',
'',
'Potassium (discrete campaign):',
' start | fight | draft <id|index>',
'',
Expand Down Expand Up @@ -76,6 +82,11 @@ export function parseGameCommand(raw: string): GameCommand {
if (head === 'draft') return parseChoiceCommand('draft', parts, trimmed);
if (head === 'equip' || head === 'unequip') return parseEquipCommand(head, parts, trimmed);
if (head === 'cheat') return parseCheatCommand(parts, trimmed);
if (head === 'warp' || head === 'tp' || head === 'goto' || head === 'jump') {
const areaId = parts[1];
if (!areaId) return { type: 'unknown', raw: trimmed };
return { type: 'warp', areaId };
}
if (INTERACT_ALIASES.has(head)) {
return { type: 'interact', target: parts.slice(1).join(' ') || undefined };
}
Expand Down
37 changes: 31 additions & 6 deletions src/game/core/console/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest';
import { TEST_BRIDGE_DIALOGUE_CATALOG } from '../ridge/content/testBridgeCatalog';
import { TEST_ROUTE_DIALOGUE_CATALOG } from '../ridge/content/testRouteCatalog';
import { parseGameCommand, parseGameScript } from './commands';
import { GameConsoleSession } from './session';

function createSession() {
return new GameConsoleSession({ dialogue: TEST_BRIDGE_DIALOGUE_CATALOG });
function createSession(
options: Omit<ConstructorParameters<typeof GameConsoleSession>[0], 'dialogue'> = {}
) {
return new GameConsoleSession({ dialogue: TEST_ROUTE_DIALOGUE_CATALOG, ...options });
}

describe('parseGameCommand', () => {
Expand Down Expand Up @@ -81,7 +83,7 @@ describe('GameConsoleSession full secret route', () => {

it('syncs Phaser position and cancels a pending banana peel when walking away', () => {
const session = new GameConsoleSession({
dialogue: TEST_BRIDGE_DIALOGUE_CATALOG,
dialogue: TEST_ROUTE_DIALOGUE_CATALOG,
ownedItemIds: ['glasses'],
equippedItemIds: ['glasses']
});
Expand All @@ -96,7 +98,7 @@ describe('GameConsoleSession full secret route', () => {

it('inserts Circuit at the CRT after hydrate', () => {
const session = new GameConsoleSession({
dialogue: TEST_BRIDGE_DIALOGUE_CATALOG,
dialogue: TEST_ROUTE_DIALOGUE_CATALOG,
ownedItemIds: ['circuit'],
equippedItemIds: []
});
Expand All @@ -106,9 +108,32 @@ describe('GameConsoleSession full secret route', () => {
expect(session.observe().mode).toBe('ridge');
});

it('returns to Overworld after Ridge dedication / route reset', () => {
const session = new GameConsoleSession({
dialogue: TEST_ROUTE_DIALOGUE_CATALOG,
ownedItemIds: ['circuit'],
sceneId: 'ridge'
});
expect(session.exec('warp relay').ok).toBe(true);
session.exec('go right 10');
expect(session.exec('interact').ok).toBe(true);
for (let i = 0; i < 20; i += 1) {
if (session.observe().ridge?.conversation?.awaitingChoice) break;
if (session.observe().mode !== 'ridge') break;
session.exec('advance');
}
expect(session.exec('choose let-song-end').ok).toBe(true);
for (let i = 0; i < 20; i += 1) {
if (session.observe().mode !== 'ridge') break;
session.exec('advance');
}
expect(session.observe().mode).toBe('overworld');
expect(session.observe().ownedItemIds).toContain('circuit');
});

it('commits banana peel warp for the live Overworld typewriter path', () => {
const session = new GameConsoleSession({
dialogue: TEST_BRIDGE_DIALOGUE_CATALOG,
dialogue: TEST_ROUTE_DIALOGUE_CATALOG,
ownedItemIds: ['glasses'],
equippedItemIds: ['glasses'],
discoveredSecretIds: ['banana-peel-clue']
Expand Down
Loading
Loading