From f3491f69e0f9625c63a099f565bbab52b2e2b38e Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Thu, 30 Jul 2026 22:32:53 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(ridge):=20ship=20first-playable=20Brid?= =?UTF-8?q?ge=E2=86=92Relay=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the console-first Ridge core across Concert, Dance Festival, and Relay so the secret CRT game can be walked end-to-end (guitar farewell, dedication, clean Bridge reset) without browser playtest loops. Co-authored-by: Cursor --- docs/game-design/player-manual.md | 2 + docs/game-design/ridge/ridge-snapshot.md | 30 +- scripts/game-console.ts | 17 +- src/game/bridge/store.test.ts | 18 +- src/game/bridge/store.ts | 70 ++- src/game/core/console/session.test.ts | 14 +- src/game/core/console/session.ts | 16 +- src/game/core/console/types.ts | 9 +- src/game/core/ridge/content/bridgeStage.ts | 32 +- src/game/core/ridge/content/concertStage.ts | 306 +++++++++++++ src/game/core/ridge/content/danceStage.ts | 431 ++++++++++++++++++ .../core/ridge/content/dialogueHelpers.ts | 38 ++ .../content/loadRouteCatalogFromMessages.ts | 48 ++ src/game/core/ridge/content/relayStage.ts | 200 ++++++++ src/game/core/ridge/content/routeStages.ts | 24 + .../core/ridge/content/testRouteCatalog.ts | 114 +++++ src/game/core/ridge/conversation.ts | 38 +- src/game/core/ridge/index.ts | 7 + src/game/core/ridge/observe.ts | 17 +- src/game/core/ridge/session.test.ts | 190 ++++++-- src/game/core/ridge/session.ts | 130 +++++- src/game/core/ridge/types.ts | 76 ++- .../scenes/basement/runtime/BasementScene.ts | 4 +- .../overworld/runtime/OverworldScene.ts | 4 +- .../ridge/art/stick/StickVisualProvider.ts | 171 +++++-- .../scenes/ridge/art/stick/stickFigures.ts | 151 +++++- src/game/scenes/ridge/art/types.ts | 14 +- .../scenes/ridge/content/bridgeCatalog.ts | 15 - src/game/scenes/ridge/content/routeCatalog.ts | 10 + src/game/scenes/ridge/runtime/RidgeScene.ts | 42 +- .../scenes/ridge/runtime/ridgeDevControls.ts | 4 +- src/shared/i18n/messages/en/scenes.ts | 132 ++++++ 32 files changed, 2103 insertions(+), 271 deletions(-) create mode 100644 src/game/core/ridge/content/concertStage.ts create mode 100644 src/game/core/ridge/content/danceStage.ts create mode 100644 src/game/core/ridge/content/dialogueHelpers.ts create mode 100644 src/game/core/ridge/content/loadRouteCatalogFromMessages.ts create mode 100644 src/game/core/ridge/content/relayStage.ts create mode 100644 src/game/core/ridge/content/routeStages.ts create mode 100644 src/game/core/ridge/content/testRouteCatalog.ts delete mode 100644 src/game/scenes/ridge/content/bridgeCatalog.ts create mode 100644 src/game/scenes/ridge/content/routeCatalog.ts diff --git a/docs/game-design/player-manual.md b/docs/game-design/player-manual.md index 6224ffb..f10e1a6 100644 --- a/docs/game-design/player-manual.md +++ b/docs/game-design/player-manual.md @@ -73,6 +73,8 @@ 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 clean reset back to Bridge. - One-shot scripts: `pnpm game:console --script "look; go right 3; interact" --json`. ## Runtime Notes For Playtesting diff --git a/docs/game-design/ridge/ridge-snapshot.md b/docs/game-design/ridge/ridge-snapshot.md index c83d3d2..872afd7 100644 --- a/docs/game-design/ridge/ridge-snapshot.md +++ b/docs/game-design/ridge/ridge-snapshot.md @@ -69,7 +69,8 @@ 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 @@ -77,10 +78,33 @@ Nature / hill entry -> 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 + -> First Playable Reset Return to Bridge ``` -Concert, Dance Festival, Relay, and the ending sequence remain future -implementation slices. Copy the Bridge console-content + stick-visual pattern. +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) diff --git a/scripts/game-console.ts b/scripts/game-console.ts index 02dc52b..d6af454 100644 --- a/scripts/game-console.ts +++ b/scripts/game-console.ts @@ -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 { diff --git a/src/game/bridge/store.test.ts b/src/game/bridge/store.test.ts index 75123df..ca272e0 100644 --- a/src/game/bridge/store.test.ts +++ b/src/game/bridge/store.test.ts @@ -174,7 +174,7 @@ describe('bridgeStore', () => { shortcutIds: [], firstPlayableRoute: { activeAreaId: 'bridge', - bridgeBeat: 'intro' + beat: 'intro' } }); expect(bridgeStore.getState().inventory.ownedItemIds).toEqual([]); @@ -204,7 +204,7 @@ describe('bridgeStore', () => { shortcutIds: [], firstPlayableRoute: { activeAreaId: 'bridge', - bridgeBeat: 'intro' + beat: 'intro' } }); expect(bridgeStore.getState().inventory.ownedItemIds).toEqual([]); @@ -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' }); }); diff --git a/src/game/bridge/store.ts b/src/game/bridge/store.ts index a880f26..324dcb2 100644 --- a/src/game/bridge/store.ts +++ b/src/game/bridge/store.ts @@ -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; + | '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; -export type RidgeFirstPlayableRouteState = - | { - activeAreaId: 'bridge'; - bridgeBeat: RidgeBridgeAreaBeatState; - } - | { - activeAreaId: RidgePostBridgeAreaId; - bridgeBeat: 'concert_handoff'; - }; +export type RidgeFirstPlayableRouteState = { + activeAreaId: RidgeFirstPlayableAreaId; + beat: RidgeRouteBeatState; +}; export interface BridgeRidgeProgressState { stampIds: RidgeStampId[]; @@ -146,7 +155,7 @@ function createInitialRidgeProgress(): BridgeRidgeProgressState { function createInitialRidgeFirstPlayableRoute(): RidgeFirstPlayableRouteState { return { activeAreaId: 'bridge', - bridgeBeat: 'intro' + beat: 'intro' }; } @@ -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 ); } @@ -523,7 +532,7 @@ export const bridgeActions = { }; }); }, - setRidgeBridgeBeat(bridgeBeat: RidgeBridgeAreaBeatState): void { + setRidgeRouteBeat(beat: RidgeRouteBeatState): void { setState((current) => ({ ...current, progress: { @@ -532,14 +541,17 @@ 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: { @@ -547,13 +559,29 @@ export const bridgeActions = { 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, diff --git a/src/game/core/console/session.test.ts b/src/game/core/console/session.test.ts index 43a8f7a..ba72dbf 100644 --- a/src/game/core/console/session.test.ts +++ b/src/game/core/console/session.test.ts @@ -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[0], 'dialogue'> = {} +) { + return new GameConsoleSession({ dialogue: TEST_ROUTE_DIALOGUE_CATALOG, ...options }); } describe('parseGameCommand', () => { @@ -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'] }); @@ -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: [] }); @@ -108,7 +110,7 @@ describe('GameConsoleSession full secret route', () => { 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'] diff --git a/src/game/core/console/session.ts b/src/game/core/console/session.ts index 0ba7ba9..1b87218 100644 --- a/src/game/core/console/session.ts +++ b/src/game/core/console/session.ts @@ -1,9 +1,9 @@ // Session dispatch mirrors many console verbs; complexity is the command surface. // fallow-ignore-file complexity import { - createBridgeStage, + createRidgeRouteStages, RidgeConsoleSession, - type BridgeDialogueCatalog, + type RidgeRouteDialogueCatalog, type RidgeSessionEvent } from '../ridge/index'; import { @@ -38,7 +38,7 @@ import type { } from './types'; export interface GameConsoleSessionOptions { - dialogue: BridgeDialogueCatalog; + dialogue: RidgeRouteDialogueCatalog; /** Start scene (default overworld). */ sceneId?: GameSceneId; ownedItemIds?: readonly GameItemId[]; @@ -49,7 +49,7 @@ export interface GameConsoleSessionOptions { export class GameConsoleSession { private state: GameWorldState; private ridge: RidgeConsoleSession | null = null; - private readonly dialogue: BridgeDialogueCatalog; + private readonly dialogue: RidgeRouteDialogueCatalog; constructor(options: GameConsoleSessionOptions) { this.dialogue = options.dialogue; @@ -360,7 +360,7 @@ export class GameConsoleSession { private ensureRidge(): RidgeConsoleSession { if (!this.ridge) { this.ridge = new RidgeConsoleSession({ - stage: createBridgeStage(this.dialogue) + stages: createRidgeRouteStages(this.dialogue) }); } return this.ridge; @@ -404,8 +404,10 @@ function mapRidgeEvents(events: readonly RidgeSessionEvent[]): GameSessionEvent[ for (const event of events) { if (event.type === 'beat_changed') { out.push({ type: 'ridge_beat_changed', beat: event.beat }); - } else if (event.type === 'concert_handoff') { - out.push({ type: 'ridge_concert_handoff' }); + } else if (event.type === 'area_handoff') { + out.push({ type: 'ridge_area_handoff', areaId: event.areaId }); + } else if (event.type === 'route_reset') { + out.push({ type: 'ridge_route_reset' }); } } return out; diff --git a/src/game/core/console/types.ts b/src/game/core/console/types.ts index 6fa5c1c..303d7bf 100644 --- a/src/game/core/console/types.ts +++ b/src/game/core/console/types.ts @@ -2,7 +2,7 @@ * Pure full-game console types. No Phaser, React, DOM, or bridge store. */ -import type { RidgeBridgeBeat, RidgeObservation } from '../ridge/types'; +import type { RidgeObservation, RidgeRouteBeat } from '../ridge/types'; import type { GameSceneId, PortfolioOverlayId } from './content/overworldSpots'; export type GameItemId = 'glasses' | 'circuit'; @@ -76,7 +76,7 @@ export interface GameWorldState { potassium: PotassiumSlice; lastMessage: string | null; /** Ridge is hosted separately; beat mirrored for observation. */ - ridgeBeat: RidgeBridgeBeat | null; + ridgeBeat: RidgeRouteBeat | null; } export interface GameObservation { @@ -128,8 +128,9 @@ export type GameSessionEvent = | { type: 'banana_peel_cancelled' } | { type: 'thought'; id: 'basement_cannot_see' } | { type: 'potassium_won' } - | { type: 'ridge_beat_changed'; beat: RidgeBridgeBeat } - | { type: 'ridge_concert_handoff' }; + | { type: 'ridge_beat_changed'; beat: RidgeRouteBeat } + | { type: 'ridge_area_handoff'; areaId: string } + | { type: 'ridge_route_reset' }; export interface GameCommandResult { ok: boolean; diff --git a/src/game/core/ridge/content/bridgeStage.ts b/src/game/core/ridge/content/bridgeStage.ts index d4bb18e..5cc9fee 100644 --- a/src/game/core/ridge/content/bridgeStage.ts +++ b/src/game/core/ridge/content/bridgeStage.ts @@ -8,8 +8,8 @@ import type { RidgeStageDefinition, RidgeWorldState } from '../types'; +import { RIDGE_TOY_CAR_ITEM } from '../types'; -const BRIDGE_TOY_CAR_ITEM = 'toy-car'; const BRIDGE_BLOCKED_PROGRESS = 0.62; export interface BridgeDialogueCatalog { @@ -89,6 +89,9 @@ export function createBridgeStage(catalog: BridgeDialogueCatalog): RidgeStageDef lengthLabel: 'farm river sketch', spots: SPOTS, blockedProgress: BRIDGE_BLOCKED_PROGRESS, + blockedMessage: + 'The unfinished bridge blocks the way. Talk to the draftsperson or finish the crossing.', + isCrossingOpen: (state) => state.beat === 'bridge_complete', resolveInteractables: (state) => resolveBridgeInteractables(state, catalog), resolveActors: (state) => resolveBridgeActors(state), resolveConversation: (conversationId, state) => @@ -101,8 +104,6 @@ function resolveBridgeInteractables( state: RidgeWorldState, catalog: BridgeDialogueCatalog ): RidgeInteractable[] { - if (state.beat === 'concert_handoff') return []; - const plans = interactPlansForBeat(state.beat); const result: RidgeInteractable[] = []; @@ -175,18 +176,10 @@ function interactPlansForBeat(beat: RidgeWorldState['beat']): BridgeInteractPlan } function resolveBridgeActors(state: RidgeWorldState): RidgeActorPresence[] { - const crossingOpen = - state.beat === 'bridge_complete' || state.beat === 'concert_handoff'; - const cickaProgress = - state.beat === 'bridge_complete' || state.beat === 'concert_handoff' - ? 0.68 - : 0.22; + const crossingOpen = state.beat === 'bridge_complete'; + const cickaProgress = crossingOpen ? 0.68 : 0.22; const toyCarProgress = - state.beat === 'toy_car_shared' - ? state.progress - : state.beat === 'bridge_complete' || state.beat === 'concert_handoff' - ? 0.7 - : 0.24; + state.beat === 'toy_car_shared' ? state.progress : crossingOpen ? 0.7 : 0.24; return [ { @@ -200,7 +193,7 @@ function resolveBridgeActors(state: RidgeWorldState): RidgeActorPresence[] { id: 'cicka', label: 'Cicka', progress: cickaProgress, - visible: state.beat !== 'concert_handoff', + visible: true, facing: 'right' }, { @@ -217,7 +210,7 @@ function resolveBridgeActors(state: RidgeWorldState): RidgeActorPresence[] { visible: state.beat === 'intro' || state.beat === 'needs_toy_car' || - state.inventory.includes(BRIDGE_TOY_CAR_ITEM) || + state.inventory.includes(RIDGE_TOY_CAR_ITEM) || crossingOpen, facing: 'right' } @@ -291,7 +284,7 @@ function resolveBridgeConversation( ], outcome: { setBeat: 'toy_car_shared', - addItem: BRIDGE_TOY_CAR_ITEM, + addItem: RIDGE_TOY_CAR_ITEM, setFlag: 'toy_car_shared' } }; @@ -318,7 +311,7 @@ function resolveBridgeConversation( line('bridge.exit.opened_crossing.03', 'prompt') ], outcome: { - concertHandoff: true + handoffToArea: 'concert' } }; default: @@ -340,9 +333,6 @@ function makeLine( } function describeBridgeAmbience(state: RidgeWorldState): string { - if (state.beat === 'concert_handoff') { - return 'The finished bridge holds. Evening music leans in from the next page.'; - } if (state.beat === 'bridge_complete') { return 'Ink settles into a real crossing. The concert exit is open to the east.'; } diff --git a/src/game/core/ridge/content/concertStage.ts b/src/game/core/ridge/content/concertStage.ts new file mode 100644 index 0000000..6a4d179 --- /dev/null +++ b/src/game/core/ridge/content/concertStage.ts @@ -0,0 +1,306 @@ +// Concert beat branching is route policy encoded as content. +// fallow-ignore-file complexity +import type { + RidgeActorPresence, + RidgeConversationDefinition, + RidgeInteractable, + RidgeStageDefinition, + RidgeWorldState +} from '../types'; +import { RIDGE_GUITAR_ITEM } from '../types'; +import { collectNearbyFromPlans, makeCatalogLine } from './dialogueHelpers'; + +const CONCERT_BLOCKED_PROGRESS = 0.55; + +export interface ConcertDialogueCatalog { + speakers: { + prompt: string; + cicka: string; + injuredGuitarist: string; + crowd: string; + }; + lines: Record; +} + +const SPOTS = [ + { + id: 'entry', + label: 'Bridge night entry', + kind: 'landmark' as const, + progress: 0.05, + interactRadius: 0.05, + description: 'Evening ink. Small-town storefronts lean toward a delayed show.' + }, + { + id: 'guitarist', + label: 'Injured Guitarist', + kind: 'npc' as const, + progress: 0.28, + interactRadius: 0.08, + actorId: 'guitarist' as const, + description: 'A musician-side nook. Skateboard regret and a quiet guitar case.' + }, + { + id: 'cicka-nook', + label: 'Cicka', + kind: 'npc' as const, + progress: 0.32, + interactRadius: 0.07, + actorId: 'cicka' as const, + description: 'Cicka watches from behind stage props, crowd-shy.' + }, + { + id: 'crowd', + label: 'Concert crowd', + kind: 'npc' as const, + progress: 0.55, + interactRadius: 0.08, + actorId: 'crowd' as const, + description: 'A patient-impatient line waiting for a late local concert.' + }, + { + id: 'stage', + label: 'Tiny stage', + kind: 'prop' as const, + progress: 0.42, + interactRadius: 0.07, + description: 'A forgiving practice spot that can become the show.' + }, + { + id: 'dance-exit', + label: 'Dance exit', + kind: 'exit' as const, + progress: 0.9, + interactRadius: 0.08, + description: 'The opened crossing leans toward Last-Stop Plaza.' + } +] as const; + +export function createConcertStage(catalog: ConcertDialogueCatalog): RidgeStageDefinition { + return { + areaId: 'concert', + title: 'Concert Area / Concert Crossing', + lengthLabel: 'small-town night block', + spots: SPOTS, + blockedProgress: CONCERT_BLOCKED_PROGRESS, + blockedMessage: + 'The crowd fills the crossing. Find the musician-side nook or wait for the show.', + isCrossingOpen: (state) => state.beat === 'concert_cleared', + resolveInteractables: (state) => resolveConcertInteractables(state, catalog), + resolveActors: (state) => resolveConcertActors(state), + resolveConversation: (conversationId, state) => + resolveConcertConversation(conversationId, state, catalog), + describeAmbience: (state) => describeConcertAmbience(state) + }; +} + +function resolveConcertInteractables( + state: RidgeWorldState, + catalog: ConcertDialogueCatalog +): RidgeInteractable[] { + const plans: { spotId: string; conversationId: string; prompt: string }[] = []; + + if (state.beat === 'concert_arrival' || state.beat === 'concert_practiced') { + plans.push({ + spotId: 'crowd', + conversationId: 'concert.crowd.delay_barks', + prompt: 'concert.crowd.delay_barks.01' + }); + plans.push({ + spotId: 'guitarist', + conversationId: + state.beat === 'concert_practiced' + ? 'concert.performance.auto_success' + : state.flags.has('met_guitarist') + ? 'concert.guitarist.practice_riff' + : 'concert.guitarist.injury', + prompt: + state.beat === 'concert_practiced' + ? 'concert.performance.auto_success.01' + : state.flags.has('met_guitarist') + ? 'concert.guitarist.practice_riff.01' + : 'concert.guitarist.injury.01' + }); + plans.push({ + spotId: 'cicka-nook', + conversationId: 'concert.cicka.band_resting_spot', + prompt: 'concert.cicka.band_resting_spot.01' + }); + if (state.beat === 'concert_practiced') { + plans.push({ + spotId: 'stage', + conversationId: 'concert.performance.auto_success', + prompt: 'concert.performance.auto_success.01' + }); + } + } + + if (state.beat === 'concert_cleared') { + plans.push({ + spotId: 'guitarist', + conversationId: 'concert.guitarist.guitar_handoff', + prompt: 'concert.guitarist.guitar_handoff.01' + }); + plans.push({ + spotId: 'cicka-nook', + conversationId: 'concert.cicka.band_resting_spot', + prompt: 'concert.cicka.band_resting_spot.03' + }); + plans.push({ + spotId: 'dance-exit', + conversationId: 'concert.exit.dance_transition', + prompt: 'concert.exit.dance_transition.01' + }); + } + + return collectNearbyFromPlans( + state.progress, + SPOTS, + plans, + (key) => catalog.lines[key] ?? key + ); +} + +function resolveConcertActors(state: RidgeWorldState): RidgeActorPresence[] { + const cleared = state.beat === 'concert_cleared'; + return [ + { + id: 'player', + label: 'You', + progress: state.progress, + visible: true, + facing: state.facing + }, + { + id: 'crowd', + label: 'Concert crowd', + progress: cleared ? 0.72 : 0.55, + visible: !cleared, + facing: 'left' + }, + { + id: 'guitarist', + label: 'Injured Guitarist', + progress: cleared ? 0.78 : 0.28, + visible: true, + facing: cleared ? 'left' : 'right' + }, + { + id: 'cicka', + label: 'Cicka', + progress: cleared ? 0.8 : 0.32, + visible: true, + facing: 'right' + }, + { + id: 'guitar', + label: 'Guitar', + progress: cleared ? state.progress : 0.3, + visible: cleared && state.inventory.includes(RIDGE_GUITAR_ITEM), + facing: 'right' + } + ]; +} + +function resolveConcertConversation( + conversationId: string, + state: RidgeWorldState, + catalog: ConcertDialogueCatalog +): RidgeConversationDefinition | null { + const line = (id: string, speakerId: keyof ConcertDialogueCatalog['speakers']) => + makeCatalogLine(catalog, id, speakerId); + + switch (conversationId) { + case 'concert.crowd.delay_barks': + return { + id: conversationId, + lines: [ + line('concert.crowd.delay_barks.01', 'crowd'), + line('concert.crowd.delay_barks.02', 'crowd'), + line('concert.crowd.delay_barks.03', 'prompt') + ] + }; + case 'concert.guitarist.injury': + return { + id: conversationId, + lines: [ + line('concert.guitarist.injury.01', 'injuredGuitarist'), + line('concert.guitarist.injury.02', 'injuredGuitarist'), + line('concert.guitarist.injury.03', 'prompt') + ], + outcome: { setFlag: 'met_guitarist' } + }; + case 'concert.guitarist.practice_riff': + return { + id: conversationId, + lines: [ + line('concert.guitarist.practice_riff.01', 'injuredGuitarist'), + line('concert.guitarist.practice_riff.02', 'prompt'), + line('concert.guitarist.practice_riff.03', 'injuredGuitarist') + ], + outcome: { setBeat: 'concert_practiced' } + }; + case 'concert.performance.auto_success': + return { + id: conversationId, + lines: [ + line('concert.performance.auto_success.01', 'prompt'), + line('concert.performance.auto_success.02', 'prompt'), + line('concert.performance.auto_success.03', 'crowd'), + line('concert.performance.auto_success.04', 'injuredGuitarist') + ], + outcome: { + setBeat: 'concert_cleared', + addItem: RIDGE_GUITAR_ITEM + } + }; + case 'concert.guitarist.guitar_handoff': + return { + id: conversationId, + lines: [ + line('concert.guitarist.guitar_handoff.01', 'injuredGuitarist'), + line('concert.guitarist.guitar_handoff.02', 'injuredGuitarist'), + line('concert.guitarist.guitar_handoff.03', 'prompt') + ] + }; + case 'concert.cicka.band_resting_spot': + return { + id: conversationId, + lines: + state.beat === 'concert_cleared' + ? [ + line('concert.cicka.band_resting_spot.03', 'prompt'), + line('concert.cicka.band_resting_spot.04', 'cicka') + ] + : [ + line('concert.cicka.band_resting_spot.01', 'prompt'), + line('concert.cicka.band_resting_spot.02', 'cicka') + ] + }; + case 'concert.exit.dance_transition': + return { + id: conversationId, + lines: [ + line('concert.exit.dance_transition.01', 'prompt'), + line('concert.exit.dance_transition.02', 'injuredGuitarist'), + line('concert.exit.dance_transition.03', 'prompt') + ], + outcome: { handoffToArea: 'danceFestival' } + }; + default: + return null; + } +} + +function describeConcertAmbience(state: RidgeWorldState): string { + if (state.beat === 'concert_cleared') { + return 'The crossing breathes again. Cicka loafs with the band near the opened exit.'; + } + if (state.beat === 'concert_practiced') { + return 'You know the phrase. The tiny stage waits for a forgiving first note.'; + } + if (state.flags.has('met_guitarist')) { + return 'Night storefronts. A late crowd. One careful practice could clear the street.'; + } + return 'A compact night block. The crossing is full of people waiting for a concert that will not start.'; +} diff --git a/src/game/core/ridge/content/danceStage.ts b/src/game/core/ridge/content/danceStage.ts new file mode 100644 index 0000000..cf7d716 --- /dev/null +++ b/src/game/core/ridge/content/danceStage.ts @@ -0,0 +1,431 @@ +// Dance Festival branching is route policy encoded as content. +// fallow-ignore-file complexity +import type { + RidgeActorPresence, + RidgeConversationDefinition, + RidgeInteractable, + RidgeStageDefinition, + RidgeWorldState +} from '../types'; +import { collectNearbyFromPlans, makeCatalogLine } from './dialogueHelpers'; + +const DANCE_BLOCKED_PROGRESS = 0.62; +const FLAG_OPS = 'ops_ready'; +const FLAG_DRIVER = 'driver_ready'; + +export interface DanceDialogueCatalog { + speakers: { + prompt: string; + cicka: string; + traveler: string; + hillShuttleDriver: string; + operationsHelper: string; + danceTeacher: string; + festivalSteward: string; + }; + lines: Record; +} + +const SPOTS = [ + { + id: 'entry', + label: 'Plaza entry', + kind: 'landmark' as const, + progress: 0.05, + interactRadius: 0.05, + description: 'Daytime setup for a night dance that has not begun.' + }, + { + id: 'traveler', + label: 'Traveler', + kind: 'npc' as const, + progress: 0.18, + interactRadius: 0.07, + actorId: 'traveler' as const, + description: 'Someone asking the same question you are: how to reach Relay.' + }, + { + id: 'driver', + label: 'Hill-Shuttle Driver', + kind: 'npc' as const, + progress: 0.35, + interactRadius: 0.08, + actorId: 'driver' as const, + description: 'Stuck on a route clipboard beside the last-daylight shuttle.' + }, + { + id: 'operations', + label: 'Last-Stop Operations Helper', + kind: 'npc' as const, + progress: 0.48, + interactRadius: 0.08, + actorId: 'operations-helper' as const, + description: 'Perfecting lanterns and chair stacks near the dance floor edge.' + }, + { + id: 'teacher', + label: 'Dance Teacher', + kind: 'npc' as const, + progress: 0.42, + interactRadius: 0.07, + actorId: 'dance-teacher' as const, + description: 'Keeps watch without forcing anyone onto the floor.' + }, + { + id: 'steward', + label: 'Festival Steward', + kind: 'npc' as const, + progress: 0.58, + interactRadius: 0.07, + actorId: 'steward' as const, + description: 'Holds the service-gate key until setup is safe.' + }, + { + id: 'cicka', + label: 'Cicka', + kind: 'npc' as const, + progress: 0.52, + interactRadius: 0.06, + actorId: 'cicka' as const, + description: 'Loafs near the operations table like a soft paperweight.' + }, + { + id: 'gate', + label: 'Service gate', + kind: 'prop' as const, + progress: 0.62, + interactRadius: 0.07, + description: 'Festival barriers and cable tape block the hill road.' + }, + { + id: 'shuttle', + label: 'Last daylight shuttle', + kind: 'exit' as const, + progress: 0.88, + interactRadius: 0.08, + actorId: 'shuttle' as const, + description: 'The threshold ride to Relay under warming sky.' + } +] as const; + +export function createDanceStage(catalog: DanceDialogueCatalog): RidgeStageDefinition { + return { + areaId: 'danceFestival', + title: 'Dance Festival / Opening Dance Shuttle', + lengthLabel: 'Last-Stop Plaza', + spots: SPOTS, + blockedProgress: DANCE_BLOCKED_PROGRESS, + blockedMessage: + 'The service road is closed for festival setup. Help the plaza become ready.', + isCrossingOpen: (state) => state.beat === 'dance_cleared', + resolveInteractables: (state) => resolveDanceInteractables(state, catalog), + resolveActors: (state) => resolveDanceActors(state), + resolveConversation: (conversationId, state) => + resolveDanceConversation(conversationId, state, catalog), + describeAmbience: (state) => describeDanceAmbience(state) + }; +} + +function bothReady(state: RidgeWorldState): boolean { + return state.flags.has(FLAG_OPS) && state.flags.has(FLAG_DRIVER); +} + +function resolveDanceInteractables( + state: RidgeWorldState, + catalog: DanceDialogueCatalog +): RidgeInteractable[] { + const plans: { spotId: string; conversationId: string; prompt: string }[] = []; + + if (state.beat === 'dance_arrival' || state.beat === 'dance_ready') { + plans.push({ + spotId: 'traveler', + conversationId: 'dance.traveler.relay_wayfinding', + prompt: 'dance.traveler.relay_wayfinding.01' + }); + plans.push({ + spotId: 'steward', + conversationId: 'dance.locals.triangulated_read', + prompt: 'dance.locals.triangulated_read.01' + }); + plans.push({ + spotId: 'teacher', + conversationId: state.flags.has(FLAG_DRIVER) + ? 'dance.locals.triangulated_read' + : 'dance.driver.one_step_practice', + prompt: state.flags.has(FLAG_DRIVER) + ? 'dance.locals.triangulated_read.03' + : 'dance.driver.one_step_practice.01' + }); + plans.push({ + spotId: 'operations', + conversationId: 'dance.operations_helper.handoff_check', + prompt: 'dance.operations_helper.handoff_check.01' + }); + plans.push({ + spotId: 'driver', + conversationId: bothReady(state) + ? 'dance.driver.folded_song_request' + : 'dance.driver.shuttle_delay', + prompt: bothReady(state) + ? 'dance.driver.folded_song_request.01' + : 'dance.driver.shuttle_delay.01' + }); + plans.push({ + spotId: 'cicka', + conversationId: 'dance.cicka.resting_spot', + prompt: 'dance.cicka.resting_spot.01' + }); + } + + if (state.beat === 'dance_ready') { + plans.push({ + spotId: 'gate', + conversationId: 'dance.setup_clearance', + prompt: 'dance.setup_clearance.01' + }); + } + + if (state.beat === 'dance_cleared') { + plans.push({ + spotId: 'shuttle', + conversationId: 'dance.shuttle.last_daylight_ride', + prompt: 'dance.shuttle.last_daylight_ride.01' + }); + plans.push({ + spotId: 'cicka', + conversationId: 'dance.cicka.resting_spot', + prompt: 'dance.cicka.resting_spot.02' + }); + } + + return collectNearbyFromPlans( + state.progress, + SPOTS, + plans, + (key) => catalog.lines[key] ?? key + ); +} + +function resolveDanceActors(state: RidgeWorldState): RidgeActorPresence[] { + const cleared = state.beat === 'dance_cleared'; + return [ + { + id: 'player', + label: 'You', + progress: state.progress, + visible: true, + facing: state.facing + }, + { + id: 'traveler', + label: 'Traveler', + progress: 0.18, + visible: !cleared, + facing: 'right' + }, + { + id: 'driver', + label: 'Hill-Shuttle Driver', + progress: cleared ? 0.86 : 0.35, + visible: true, + facing: cleared ? 'right' : 'left' + }, + { + id: 'operations-helper', + label: 'Last-Stop Operations Helper', + progress: cleared ? 0.7 : 0.48, + visible: true, + facing: 'left' + }, + { + id: 'dance-teacher', + label: 'Dance Teacher', + progress: 0.42, + visible: true, + facing: 'right' + }, + { + id: 'steward', + label: 'Festival Steward', + progress: cleared ? 0.64 : 0.58, + visible: true, + facing: 'left' + }, + { + id: 'cicka', + label: 'Cicka', + progress: cleared ? 0.66 : 0.52, + visible: true, + facing: 'right' + }, + { + id: 'counterpart-cat', + label: 'Quiet counterpart cat', + progress: 0.68, + visible: cleared, + facing: 'left' + }, + { + id: 'shuttle', + label: 'Last daylight shuttle', + progress: 0.88, + visible: true, + facing: 'right' + } + ]; +} + +function resolveDanceConversation( + conversationId: string, + state: RidgeWorldState, + catalog: DanceDialogueCatalog +): RidgeConversationDefinition | null { + const line = (id: string, speakerId: keyof DanceDialogueCatalog['speakers']) => + makeCatalogLine(catalog, id, speakerId); + + switch (conversationId) { + case 'dance.traveler.relay_wayfinding': + return { + id: conversationId, + lines: [ + line('dance.traveler.relay_wayfinding.01', 'traveler'), + line('dance.traveler.relay_wayfinding.02', 'traveler'), + line('dance.traveler.relay_wayfinding.03', 'prompt') + ] + }; + case 'dance.driver.shuttle_delay': + return { + id: conversationId, + lines: [ + line('dance.driver.shuttle_delay.01', 'hillShuttleDriver'), + line('dance.driver.shuttle_delay.02', 'hillShuttleDriver'), + line('dance.driver.shuttle_delay.03', 'prompt') + ], + choices: state.flags.has(FLAG_DRIVER) + ? undefined + : [ + { + id: 'offer-help', + label: 'Maybe one small step would help', + lines: [ + line('dance.driver.shuttle_delay.choice.help', 'hillShuttleDriver') + ], + outcome: undefined + }, + { + id: 'wait', + label: 'I will ask around the plaza', + lines: [line('dance.driver.shuttle_delay.choice.wait', 'prompt')] + } + ] + }; + case 'dance.operations_helper.handoff_check': + return { + id: conversationId, + lines: state.flags.has(FLAG_OPS) + ? [ + line('dance.operations_helper.handoff_check.done.01', 'operationsHelper'), + line('dance.operations_helper.handoff_check.done.02', 'prompt') + ] + : [ + line('dance.operations_helper.handoff_check.01', 'operationsHelper'), + line('dance.operations_helper.handoff_check.02', 'operationsHelper'), + line('dance.operations_helper.handoff_check.03', 'prompt') + ], + outcome: state.flags.has(FLAG_OPS) + ? undefined + : { + setFlag: FLAG_OPS, + setBeat: state.flags.has(FLAG_DRIVER) ? 'dance_ready' : undefined + } + }; + case 'dance.locals.triangulated_read': + return { + id: conversationId, + lines: [ + line('dance.locals.triangulated_read.01', 'festivalSteward'), + line('dance.locals.triangulated_read.02', 'festivalSteward'), + line('dance.locals.triangulated_read.03', 'danceTeacher') + ] + }; + case 'dance.driver.one_step_practice': + return { + id: conversationId, + lines: state.flags.has(FLAG_DRIVER) + ? [ + line('dance.driver.one_step_practice.done.01', 'danceTeacher'), + line('dance.driver.one_step_practice.done.02', 'prompt') + ] + : [ + line('dance.driver.one_step_practice.01', 'danceTeacher'), + line('dance.driver.one_step_practice.02', 'prompt'), + line('dance.driver.one_step_practice.03', 'hillShuttleDriver') + ], + outcome: state.flags.has(FLAG_DRIVER) + ? undefined + : { + setFlag: FLAG_DRIVER, + setBeat: state.flags.has(FLAG_OPS) ? 'dance_ready' : undefined + } + }; + case 'dance.driver.folded_song_request': + return { + id: conversationId, + lines: [ + line('dance.driver.folded_song_request.01', 'hillShuttleDriver'), + line('dance.driver.folded_song_request.02', 'prompt'), + line('dance.driver.folded_song_request.03', 'operationsHelper'), + line('dance.driver.folded_song_request.04', 'prompt') + ], + outcome: { setBeat: 'dance_ready', setFlag: 'song_folded' } + }; + case 'dance.setup_clearance': + return { + id: conversationId, + lines: [ + line('dance.setup_clearance.01', 'prompt'), + line('dance.setup_clearance.02', 'prompt'), + line('dance.setup_clearance.03', 'prompt'), + line('dance.setup_clearance.04', 'festivalSteward'), + line('dance.setup_clearance.05', 'prompt') + ], + outcome: { setBeat: 'dance_cleared' } + }; + case 'dance.shuttle.last_daylight_ride': + return { + id: conversationId, + lines: [ + line('dance.shuttle.last_daylight_ride.01', 'hillShuttleDriver'), + line('dance.shuttle.last_daylight_ride.02', 'prompt'), + line('dance.shuttle.last_daylight_ride.03', 'prompt') + ], + outcome: { handoffToArea: 'relay' } + }; + case 'dance.cicka.resting_spot': + return { + id: conversationId, + lines: + state.beat === 'dance_cleared' + ? [ + line('dance.cicka.resting_spot.02', 'prompt'), + line('dance.cicka.resting_spot.03', 'cicka') + ] + : [line('dance.cicka.resting_spot.01', 'prompt'), line('dance.cicka.resting_spot.03', 'cicka')] + }; + default: + return null; + } +} + +function describeDanceAmbience(state: RidgeWorldState): string { + if (state.beat === 'dance_cleared') { + return 'Lanterns settle. The last daylight shuttle waits with a warm sky behind the hill.'; + } + if (state.beat === 'dance_ready') { + return 'Both shy helpers are ready. One more setup clearance and the road can open.'; + } + if (bothReady(state)) { + return 'The plaza almost breathes. A folded song request could finish the practical courage.'; + } + return 'Daytime festival setup. Barriers, lantern crates, and a shuttle that cannot leave yet.'; +} diff --git a/src/game/core/ridge/content/dialogueHelpers.ts b/src/game/core/ridge/content/dialogueHelpers.ts new file mode 100644 index 0000000..f030095 --- /dev/null +++ b/src/game/core/ridge/content/dialogueHelpers.ts @@ -0,0 +1,38 @@ +import type { RidgeDialogueLine, RidgeInteractable, RidgeSpotDefinition } from '../types'; + +export function makeCatalogLine( + catalog: { speakers: Record; lines: Record }, + id: string, + speakerId: string +): RidgeDialogueLine { + return { + id, + speakerId, + speaker: catalog.speakers[speakerId] ?? speakerId, + text: catalog.lines[id] ?? id + }; +} + +export function collectNearbyFromPlans( + stateProgress: number, + spots: readonly RidgeSpotDefinition[], + plans: readonly { spotId: string; conversationId: string; prompt: string }[], + resolvePrompt: (promptKey: string) => string +): RidgeInteractable[] { + const result: RidgeInteractable[] = []; + for (const plan of plans) { + const spot = spots.find((candidate) => candidate.id === plan.spotId); + if (!spot) continue; + const distance = Math.abs(stateProgress - spot.progress); + if (distance > spot.interactRadius) continue; + result.push({ + spotId: spot.id, + label: spot.label, + kind: spot.kind, + distance, + prompt: resolvePrompt(plan.prompt), + conversationId: plan.conversationId + }); + } + return result; +} diff --git a/src/game/core/ridge/content/loadRouteCatalogFromMessages.ts b/src/game/core/ridge/content/loadRouteCatalogFromMessages.ts new file mode 100644 index 0000000..d6f6686 --- /dev/null +++ b/src/game/core/ridge/content/loadRouteCatalogFromMessages.ts @@ -0,0 +1,48 @@ +import type { BridgeDialogueCatalog } from './bridgeStage'; +import type { ConcertDialogueCatalog } from './concertStage'; +import type { DanceDialogueCatalog } from './danceStage'; +import type { RelayDialogueCatalog } from './relayStage'; +import type { RidgeRouteDialogueCatalog } from './routeStages'; + +/** Plain message shape shared by i18n and the Node CLI. */ +export interface RidgeRouteMessageBundle { + bridge: { + speakers: BridgeDialogueCatalog['speakers']; + dialogue: Record; + }; + concert: { + speakers: ConcertDialogueCatalog['speakers']; + dialogue: Record; + }; + dance: { + speakers: DanceDialogueCatalog['speakers']; + dialogue: Record; + }; + relay: { + speakers: RelayDialogueCatalog['speakers']; + dialogue: Record; + }; +} + +export function loadRouteCatalogFromMessages( + ridge: RidgeRouteMessageBundle +): RidgeRouteDialogueCatalog { + return { + bridge: { + speakers: { ...ridge.bridge.speakers }, + lines: { ...ridge.bridge.dialogue } + }, + concert: { + speakers: { ...ridge.concert.speakers }, + lines: { ...ridge.concert.dialogue } + }, + dance: { + speakers: { ...ridge.dance.speakers }, + lines: { ...ridge.dance.dialogue } + }, + relay: { + speakers: { ...ridge.relay.speakers }, + lines: { ...ridge.relay.dialogue } + } + }; +} diff --git a/src/game/core/ridge/content/relayStage.ts b/src/game/core/ridge/content/relayStage.ts new file mode 100644 index 0000000..d7dea45 --- /dev/null +++ b/src/game/core/ridge/content/relayStage.ts @@ -0,0 +1,200 @@ +// Relay ending sequence is intentionally linear authored content. +// fallow-ignore-file complexity +import type { + RidgeActorPresence, + RidgeConversationDefinition, + RidgeInteractable, + RidgeStageDefinition, + RidgeWorldState +} from '../types'; +import { RIDGE_GUITAR_ITEM } from '../types'; +import { collectNearbyFromPlans, makeCatalogLine } from './dialogueHelpers'; + +export interface RelayDialogueCatalog { + speakers: { + prompt: string; + cicka: string; + dedication: string; + }; + lines: Record; +} + +const SPOTS = [ + { + id: 'arrival', + label: 'Relay overlook', + kind: 'landmark' as const, + progress: 0.2, + interactRadius: 0.07, + description: 'A tiny sunset overlook above the finished route.' + }, + { + id: 'cicka', + label: 'Cicka', + kind: 'npc' as const, + progress: 0.55, + interactRadius: 0.1, + actorId: 'cicka' as const, + description: 'Cicka waits in her final field-presence spot, calm and familiar.' + }, + { + id: 'sit', + label: 'Sit and Play', + kind: 'prop' as const, + progress: 0.58, + interactRadius: 0.1, + description: 'A quiet place to sit beside Cicka with the concert guitar.' + }, + { + id: 'threshold', + label: 'Warm threshold', + kind: 'landmark' as const, + progress: 0.85, + interactRadius: 0.05, + description: 'A warm sketchbook seam beyond the player path.' + } +] as const; + +export function createRelayStage(catalog: RelayDialogueCatalog): RidgeStageDefinition { + return { + areaId: 'relay', + title: 'Relay Spire / Guitar Farewell', + lengthLabel: 'sunset overlook', + spots: SPOTS, + resolveInteractables: (state) => resolveRelayInteractables(state, catalog), + resolveActors: (state) => resolveRelayActors(state), + resolveConversation: (conversationId, state) => + resolveRelayConversation(conversationId, state, catalog), + describeAmbience: (state) => describeRelayAmbience(state) + }; +} + +function resolveRelayInteractables( + state: RidgeWorldState, + catalog: RelayDialogueCatalog +): RidgeInteractable[] { + if (state.beat !== 'relay_linger') return []; + + const plans = [ + { + spotId: 'arrival', + conversationId: 'relay.overlook.inspect', + prompt: 'relay.overlook.inspect.01' + }, + { + spotId: 'cicka', + conversationId: 'relay.sit_and_play.prompt', + prompt: 'relay.sit_and_play.prompt.01' + }, + { + spotId: 'sit', + conversationId: 'relay.sit_and_play.prompt', + prompt: 'relay.sit_and_play.prompt.01' + } + ]; + + return collectNearbyFromPlans( + state.progress, + SPOTS, + plans, + (key) => catalog.lines[key] ?? key + ); +} + +function resolveRelayActors(state: RidgeWorldState): RidgeActorPresence[] { + const farewell = + state.beat === 'relay_farewell' || + state.beat === 'relay_complete' || + state.mode === 'conversation'; + const cickaGone = state.beat === 'relay_complete' || state.flags.has('cicka_crossed'); + + return [ + { + id: 'player', + label: 'You', + progress: farewell ? 0.58 : state.progress, + visible: true, + facing: state.facing + }, + { + id: 'cicka', + label: 'Cicka', + progress: cickaGone ? 0.85 : 0.55, + visible: !cickaGone, + facing: 'left' + }, + { + id: 'guitar', + label: 'Guitar', + progress: farewell ? 0.58 : state.progress, + visible: state.inventory.includes(RIDGE_GUITAR_ITEM), + facing: 'right' + } + ]; +} + +function resolveRelayConversation( + conversationId: string, + _state: RidgeWorldState, + catalog: RelayDialogueCatalog +): RidgeConversationDefinition | null { + const line = (id: string, speakerId: keyof RelayDialogueCatalog['speakers']) => + makeCatalogLine(catalog, id, speakerId); + + switch (conversationId) { + case 'relay.overlook.inspect': + return { + id: conversationId, + lines: [ + line('relay.overlook.inspect.01', 'prompt'), + line('relay.overlook.inspect.02', 'prompt') + ] + }; + case 'relay.sit_and_play.prompt': + return { + id: conversationId, + lines: [ + line('relay.sit_and_play.prompt.01', 'prompt'), + line('relay.sit_and_play.prompt.02', 'prompt'), + line('relay.sit_and_play.prompt.03', 'prompt'), + // Route Memory Montage — three soft flashes, no captions in presentation; + // console keeps short readable echoes for AI playtests. + line('relay.montage.bridge.01', 'prompt'), + line('relay.montage.concert.01', 'prompt'), + line('relay.montage.dance.01', 'prompt'), + line('relay.guitar.sunset.01', 'prompt') + ], + choices: [ + { + id: 'let-song-end', + label: catalog.lines['relay.guitar.let_song_end.01'] ?? 'Let the song end', + lines: [ + line('relay.guitar.let_song_end.02', 'prompt'), + line('relay.cicka.threshold_meow.01', 'cicka'), + line('relay.cicka.threshold_meow.02', 'prompt'), + line('relay.cicka.threshold_meow.03', 'prompt'), + line('relay.dedication.card.01', 'dedication'), + line('relay.dedication.card.02', 'dedication') + ], + outcome: { + setBeat: 'relay_complete', + setFlag: 'cicka_crossed', + routeReset: true + } + } + ] + }; + default: + return null; + } +} + +function describeRelayAmbience(state: RidgeWorldState): string { + if (state.beat === 'relay_complete') { + return 'Empty sunset paper. A quiet dedication holds, then the page resets.'; + } + if (state.beat === 'relay_farewell') { + return 'The familiar concert phrase softens the overlook. The sun lowers.'; + } + return 'Relay Spire under warm threshold light. Cicka waits. The guitar feels ready.'; +} diff --git a/src/game/core/ridge/content/routeStages.ts b/src/game/core/ridge/content/routeStages.ts new file mode 100644 index 0000000..82692d8 --- /dev/null +++ b/src/game/core/ridge/content/routeStages.ts @@ -0,0 +1,24 @@ +import type { RidgeStageRegistry } from '../types'; +import { createBridgeStage, type BridgeDialogueCatalog } from './bridgeStage'; +import { createConcertStage, type ConcertDialogueCatalog } from './concertStage'; +import { createDanceStage, type DanceDialogueCatalog } from './danceStage'; +import { createRelayStage, type RelayDialogueCatalog } from './relayStage'; + +export interface RidgeRouteDialogueCatalog { + bridge: BridgeDialogueCatalog; + concert: ConcertDialogueCatalog; + dance: DanceDialogueCatalog; + relay: RelayDialogueCatalog; +} + +/** Build the full first-playable Compact Ridge Stage registry. */ +export function createRidgeRouteStages( + catalog: RidgeRouteDialogueCatalog +): RidgeStageRegistry { + return { + bridge: createBridgeStage(catalog.bridge), + concert: createConcertStage(catalog.concert), + danceFestival: createDanceStage(catalog.dance), + relay: createRelayStage(catalog.relay) + }; +} diff --git a/src/game/core/ridge/content/testRouteCatalog.ts b/src/game/core/ridge/content/testRouteCatalog.ts new file mode 100644 index 0000000..16f1564 --- /dev/null +++ b/src/game/core/ridge/content/testRouteCatalog.ts @@ -0,0 +1,114 @@ +import type { RidgeRouteDialogueCatalog } from './routeStages'; +import { TEST_BRIDGE_DIALOGUE_CATALOG } from './testBridgeCatalog'; + +/** Shared dialogue stub for full-route Ridge console unit tests. */ +export const TEST_ROUTE_DIALOGUE_CATALOG: RidgeRouteDialogueCatalog = { + bridge: TEST_BRIDGE_DIALOGUE_CATALOG, + concert: { + speakers: { + prompt: 'Prompt', + cicka: 'Cicka', + injuredGuitarist: 'Injured Guitarist', + crowd: 'Crowd' + }, + lines: { + 'concert.crowd.delay_barks.01': 'Concert is late again.', + 'concert.crowd.delay_barks.02': 'Someone said the guitarist wiped out.', + 'concert.crowd.delay_barks.03': 'Maybe check behind the stage props.', + 'concert.guitarist.injury.01': 'I tried a one-leg skateboard solo.', + 'concert.guitarist.injury.02': 'Wrist says no. Pride says louder no.', + 'concert.guitarist.injury.03': 'Learn the phrase with me', + 'concert.guitarist.practice_riff.01': 'Practice the forgiving riff', + 'concert.guitarist.practice_riff.02': 'You find the phrase without failing.', + 'concert.guitarist.practice_riff.03': 'That is enough courage for a street.', + 'concert.performance.auto_success.01': 'Start the concert', + 'concert.performance.auto_success.02': 'The phrase lands. Soft. True.', + 'concert.performance.auto_success.03': 'Alright, move—show happened.', + 'concert.performance.auto_success.04': 'Take the guitar. Carry the comfort.', + 'concert.guitarist.guitar_handoff.01': 'Keep it for the road ahead.', + 'concert.guitarist.guitar_handoff.02': 'Play it when quiet needs company.', + 'concert.guitarist.guitar_handoff.03': 'The guitar rests against your side.', + 'concert.cicka.band_resting_spot.01': 'Sit near hidden Cicka', + 'concert.cicka.band_resting_spot.02': 'mrrp.', + 'concert.cicka.band_resting_spot.03': 'Cicka loafs with the band', + 'concert.cicka.band_resting_spot.04': 'purr.', + 'concert.exit.dance_transition.01': 'Follow the opened crossing', + 'concert.exit.dance_transition.02': 'Festival setup waits downhill.', + 'concert.exit.dance_transition.03': 'The page warms toward afternoon.' + } + }, + dance: { + speakers: { + prompt: 'Prompt', + cicka: 'Cicka', + traveler: 'Traveler', + hillShuttleDriver: 'Hill-Shuttle Driver', + operationsHelper: 'Last-Stop Operations Helper', + danceTeacher: 'Dance Teacher', + festivalSteward: 'Festival Steward' + }, + lines: { + 'dance.traveler.relay_wayfinding.01': 'Relay is up the hill shuttle.', + 'dance.traveler.relay_wayfinding.02': 'Last daylight ride, after setup clears.', + 'dance.traveler.relay_wayfinding.03': 'Ask at the shuttle and operations table.', + 'dance.driver.shuttle_delay.01': 'Cannot leave until the steward opens the gate.', + 'dance.driver.shuttle_delay.02': 'Clipboard says ready. Feet say otherwise.', + 'dance.driver.shuttle_delay.03': 'He keeps rereading the same line.', + 'dance.driver.shuttle_delay.choice.help': 'One step. Privately. Maybe.', + 'dance.driver.shuttle_delay.choice.wait': 'You give him space and ask around.', + 'dance.operations_helper.handoff_check.01': 'If the lantern line fails, I fail.', + 'dance.operations_helper.handoff_check.02': 'Check the details with me once.', + 'dance.operations_helper.handoff_check.03': 'Help with the operations handoff', + 'dance.operations_helper.handoff_check.done.01': 'The teacher can keep watch.', + 'dance.operations_helper.handoff_check.done.02': 'She almost believes the plaza can breathe.', + 'dance.locals.triangulated_read.01': 'Road opens after setup check.', + 'dance.locals.triangulated_read.02': 'She waits on one perfect lantern. He waits on anything except asking.', + 'dance.locals.triangulated_read.03': 'They practice the art of being unavailable.', + 'dance.driver.one_step_practice.01': 'One private step. No audience.', + 'dance.driver.one_step_practice.02': 'He learns exactly one shared rhythm.', + 'dance.driver.one_step_practice.03': 'Okay. I can offer that much later.', + 'dance.driver.one_step_practice.done.01': 'He has his one step.', + 'dance.driver.one_step_practice.done.02': 'Dignity intact.', + 'dance.driver.folded_song_request.01': 'Help me fold a tiny song request.', + 'dance.driver.folded_song_request.02': 'No confession. Just one dance later.', + 'dance.driver.folded_song_request.03': 'She reads it. Soft nod. No spotlight.', + 'dance.driver.folded_song_request.04': 'The practical courage lands.', + 'dance.setup_clearance.01': 'Help with remaining preparations', + 'dance.setup_clearance.02': 'Secure the lantern line.', + 'dance.setup_clearance.03': 'Tape the service-lane clear.', + 'dance.setup_clearance.04': 'Gate open. Last daylight window.', + 'dance.setup_clearance.05': 'Flip the shuttle sign.', + 'dance.shuttle.last_daylight_ride.01': 'All aboard the last ride.', + 'dance.shuttle.last_daylight_ride.02': 'The hill lifts into sunset paper.', + 'dance.shuttle.last_daylight_ride.03': 'Relay waits quiet above.', + 'dance.cicka.resting_spot.01': 'Cicka loafs on the operations table', + 'dance.cicka.resting_spot.02': 'Cicka settles by the cleared gate', + 'dance.cicka.resting_spot.03': 'mrrp.' + } + }, + relay: { + speakers: { + prompt: 'Prompt', + cicka: 'Cicka', + dedication: 'Dedication' + }, + lines: { + 'relay.overlook.inspect.01': 'Look out over the finished route', + 'relay.overlook.inspect.02': 'The pages below still hold their changes.', + 'relay.sit_and_play.prompt.01': 'Sit and play beside Cicka', + 'relay.sit_and_play.prompt.02': 'You settle. The guitar finds the concert phrase.', + 'relay.sit_and_play.prompt.03': 'The overlook softens.', + 'relay.montage.bridge.01': '(memory) The finished bridge holds.', + 'relay.montage.concert.01': '(memory) The crossing clears; the guitar changes hands.', + 'relay.montage.dance.01': '(memory) Night lanterns wake after you leave.', + 'relay.guitar.sunset.01': 'Sunset lowers while the phrase keeps breathing.', + 'relay.guitar.let_song_end.01': 'Let the song end', + 'relay.guitar.let_song_end.02': 'The phrase resolves into quiet.', + 'relay.cicka.threshold_meow.01': 'mrrp.', + 'relay.cicka.threshold_meow.02': 'Cicka turns back once, then slips into warm paper light.', + 'relay.cicka.threshold_meow.03': 'The overlook holds empty for a breath.', + 'relay.dedication.card.01': 'For Cicka.', + 'relay.dedication.card.02': 'Thank you for playing.' + } + } +}; diff --git a/src/game/core/ridge/conversation.ts b/src/game/core/ridge/conversation.ts index 3e8010f..64b7e49 100644 --- a/src/game/core/ridge/conversation.ts +++ b/src/game/core/ridge/conversation.ts @@ -1,3 +1,5 @@ +// Outcome application branches across inventory/flags/handoffs by design. +// fallow-ignore-file complexity import type { RidgeConversationDefinition, RidgeConversationOutcome, @@ -174,7 +176,6 @@ function applyOutcome( state: RidgeWorldState, outcome: RidgeConversationOutcome ): { state: RidgeWorldState; events: RidgeSessionEvent[] } { - let next = state; const events: RidgeSessionEvent[] = []; const flags = new Set(state.flags); @@ -192,6 +193,10 @@ function applyOutcome( inventory = [...inventory, outcome.addItem]; events.push({ type: 'item_added', itemId: outcome.addItem }); } + if (outcome.removeItem && inventory.includes(outcome.removeItem)) { + inventory = inventory.filter((item) => item !== outcome.removeItem); + events.push({ type: 'item_removed', itemId: outcome.removeItem }); + } let beat = state.beat; if (outcome.setBeat) { @@ -199,28 +204,33 @@ function applyOutcome( events.push({ type: 'beat_changed', beat: outcome.setBeat }); } - if (outcome.concertHandoff) { - beat = 'concert_handoff'; - events.push({ type: 'concert_handoff' }); + if (outcome.handoffToArea) { + events.push({ type: 'area_handoff', areaId: outcome.handoffToArea }); } - next = { - ...state, - flags, - inventory, - beat, - areaId: outcome.concertHandoff ? 'concert' : state.areaId, - lastMessage: describeOutcome(outcome) - }; + if (outcome.routeReset) { + events.push({ type: 'route_reset' }); + } - return { state: next, events }; + return { + state: { + ...state, + flags, + inventory, + beat, + lastMessage: describeOutcome(outcome) + }, + events + }; } function describeOutcome(outcome: RidgeConversationOutcome): string { const parts: string[] = []; if (outcome.addItem) parts.push(`Got: ${outcome.addItem}`); + if (outcome.removeItem) parts.push(`Left: ${outcome.removeItem}`); if (outcome.setBeat) parts.push(`Beat -> ${outcome.setBeat}`); - if (outcome.concertHandoff) parts.push('Handoff -> concert'); + if (outcome.handoffToArea) parts.push(`Handoff -> ${outcome.handoffToArea}`); + if (outcome.routeReset) parts.push('Route resets to Bridge'); return parts.length > 0 ? parts.join(' | ') : 'Conversation ended.'; } diff --git a/src/game/core/ridge/index.ts b/src/game/core/ridge/index.ts index 56ee248..f24da8f 100644 --- a/src/game/core/ridge/index.ts +++ b/src/game/core/ridge/index.ts @@ -4,3 +4,10 @@ export * from './observe'; export * from './conversation'; export * from './session'; export * from './content/bridgeStage'; +export * from './content/concertStage'; +export * from './content/danceStage'; +export * from './content/relayStage'; +export * from './content/routeStages'; +export * from './content/loadRouteCatalogFromMessages'; +export * from './content/testBridgeCatalog'; +export * from './content/testRouteCatalog'; diff --git a/src/game/core/ridge/observe.ts b/src/game/core/ridge/observe.ts index 8164d0b..7f6df57 100644 --- a/src/game/core/ridge/observe.ts +++ b/src/game/core/ridge/observe.ts @@ -14,11 +14,11 @@ export function observeRidgeWorld( (a, b) => a.distance - b.distance ); const actors = stage.resolveActors(state); + const crossingOpen = stage.isCrossingOpen?.(state) ?? true; const blockedAhead = state.facing === 'right' && stage.blockedProgress !== undefined && - state.beat !== 'bridge_complete' && - state.beat !== 'concert_handoff' && + !crossingOpen && state.progress >= stage.blockedProgress - 0.001; const conversation = state.conversation @@ -41,7 +41,7 @@ export function observeRidgeWorld( })() : null; - const hints = buildHints(state, nearby, blockedAhead); + const hints = buildHints(state, stage, nearby, blockedAhead); return { mode: state.mode, @@ -65,6 +65,7 @@ export function observeRidgeWorld( function buildHints( state: RidgeWorldState, + stage: RidgeStageDefinition, nearby: RidgeObservation['nearby'], blockedAhead: boolean ): string[] { @@ -85,10 +86,12 @@ function buildHints( hints.unshift(`interact — ${nearby[0].prompt} (${nearby[0].label})`); } if (blockedAhead) { - hints.unshift('The unfinished bridge blocks the path east until it is completed.'); + hints.unshift( + stage.blockedMessage ?? 'The path east is blocked until the local problem clears.' + ); } - if (state.beat === 'concert_handoff') { - hints.unshift('Bridge handoff complete. Concert area is next (not implemented yet).'); + if (state.beat === 'relay_complete') { + hints.unshift('The dedication fades. The page will return to the Bridge.'); } return hints; @@ -98,7 +101,7 @@ export function formatObservation(observation: RidgeObservation): string { const lines: string[] = []; lines.push(`# ${observation.title}`); lines.push( - `mode=${observation.mode} beat=${observation.beat} progress=${observation.progressPercent}% facing=${observation.facing}` + `mode=${observation.mode} area=${observation.areaId} beat=${observation.beat} progress=${observation.progressPercent}% facing=${observation.facing}` ); lines.push(observation.ambience); diff --git a/src/game/core/ridge/session.test.ts b/src/game/core/ridge/session.test.ts index 02cde90..56425ad 100644 --- a/src/game/core/ridge/session.test.ts +++ b/src/game/core/ridge/session.test.ts @@ -1,11 +1,22 @@ import { describe, expect, it } from 'vitest'; import { createBridgeStage } from './content/bridgeStage'; +import { createRidgeRouteStages } from './content/routeStages'; import { TEST_BRIDGE_DIALOGUE_CATALOG } from './content/testBridgeCatalog'; +import { TEST_ROUTE_DIALOGUE_CATALOG } from './content/testRouteCatalog'; import { parseRidgeCommand, parseRidgeScript } from './commands'; import { RidgeConsoleSession } from './session'; +import type { RidgeSessionEvent } from './types'; -function createSession() { - return new RidgeConsoleSession({ stage: createBridgeStage(TEST_BRIDGE_DIALOGUE_CATALOG) }); +function createBridgeOnlySession() { + return new RidgeConsoleSession({ + stage: createBridgeStage(TEST_BRIDGE_DIALOGUE_CATALOG) + }); +} + +function createRouteSession() { + return new RidgeConsoleSession({ + stages: createRidgeRouteStages(TEST_ROUTE_DIALOGUE_CATALOG) + }); } describe('parseRidgeCommand', () => { @@ -30,58 +41,21 @@ describe('parseRidgeCommand', () => { describe('RidgeConsoleSession Bridge playthrough', () => { it('runs semicolon scripts', () => { - const session = createSession(); + const session = createBridgeOnlySession(); const results = session.execScript('look; go right 1'); expect(results.map((r) => r.ok)).toEqual([true, true]); }); it('walks the Bridge tracer end-to-end through console commands', () => { - const session = createSession(); - - // Reach Cicka and meet her (with Persona-style choice). - walkTo(session, 0.22); - expect(session.exec('interact cicka').ok).toBe(true); - drainConversation(session); - expect(session.observe().conversation?.awaitingChoice).toBe(true); - expect(session.exec('choose pet').ok).toBe(true); - drainConversation(session); - expect(session.observe().mode).toBe('explore'); - - // Learn about the missing span. - walkTo(session, 0.55); - expect(session.exec('interact draftsperson').ok).toBe(true); - drainConversation(session); - expect(session.observe().beat).toBe('needs_toy_car'); - - // Soft wall before the bridge opens. - const blocked = session.exec('go right 5'); - expect(blocked.ok).toBe(false); - expect(session.observe().progress).toBeLessThanOrEqual(0.62); - - // Retrieve toy car via parallel play. - walkTo(session, 0.22); - expect(session.exec('interact').ok).toBe(true); - drainConversation(session); - expect(session.observe().beat).toBe('toy_car_shared'); - expect(session.observe().inventory).toContain('toy-car'); - - // Auto-success toy car test opens the crossing. - walkTo(session, 0.55); - expect(session.exec('interact').ok).toBe(true); - drainConversation(session); - expect(session.observe().beat).toBe('bridge_complete'); - - // Exit handoff. - walkTo(session, 0.9); - expect(session.exec('interact').ok).toBe(true); - const events = drainConversation(session); - expect(events.some((event) => event.type === 'concert_handoff')).toBe(true); - expect(session.observe().beat).toBe('concert_handoff'); - expect(session.observe().areaId).toBe('concert'); + const session = createBridgeOnlySession(); + playBridge(session); + const observation = session.observe(); + expect(observation.areaId).toBe('concert'); + expect(observation.beat).toBe('concert_arrival'); }); it('exposes nearby distances useful for AI agents', () => { - const session = createSession(); + const session = createBridgeOnlySession(); walkTo(session, 0.22); const observation = session.observe(); expect(observation.nearby.some((item) => item.spotId === 'cicka')).toBe(true); @@ -90,6 +64,126 @@ describe('RidgeConsoleSession Bridge playthrough', () => { }); }); +describe('RidgeConsoleSession first-playable full route', () => { + it('plays Bridge → Concert → Dance → Relay farewell → Bridge reset', () => { + const session = createRouteSession(); + + playBridge(session); + expect(session.observe().areaId).toBe('concert'); + expect(session.observe().title).toContain('Concert'); + + playConcert(session); + expect(session.observe().areaId).toBe('danceFestival'); + expect(session.observe().inventory).toContain('guitar'); + + playDance(session); + expect(session.observe().areaId).toBe('relay'); + + const endingEvents = playRelay(session); + expect(endingEvents.some((event) => event.type === 'route_reset')).toBe(true); + expect(session.observe().areaId).toBe('bridge'); + expect(session.observe().beat).toBe('intro'); + expect(session.observe().inventory).toEqual([]); + expect(session.format()).toContain('Bridge Area'); + }); +}); + +function playBridge(session: RidgeConsoleSession): RidgeSessionEvent[] { + const events: RidgeSessionEvent[] = []; + + walkTo(session, 0.22); + expect(session.exec('interact cicka').ok).toBe(true); + events.push(...drainConversation(session)); + expect(session.exec('choose pet').ok).toBe(true); + events.push(...drainConversation(session)); + + walkTo(session, 0.55); + expect(session.exec('interact draftsperson').ok).toBe(true); + events.push(...drainConversation(session)); + expect(session.observe().beat).toBe('needs_toy_car'); + + const blocked = session.exec('go right 5'); + expect(blocked.ok).toBe(false); + + walkTo(session, 0.22); + expect(session.exec('interact').ok).toBe(true); + events.push(...drainConversation(session)); + expect(session.observe().inventory).toContain('toy-car'); + + walkTo(session, 0.55); + expect(session.exec('interact').ok).toBe(true); + events.push(...drainConversation(session)); + expect(session.observe().beat).toBe('bridge_complete'); + + walkTo(session, 0.9); + expect(session.exec('interact').ok).toBe(true); + events.push(...drainConversation(session)); + expect(events.some((event) => event.type === 'area_handoff')).toBe(true); + return events; +} + +function playConcert(session: RidgeConsoleSession): void { + walkTo(session, 0.55); + expect(session.exec('interact crowd').ok).toBe(true); + drainConversation(session); + + walkTo(session, 0.28); + expect(session.exec('interact guitarist').ok).toBe(true); + drainConversation(session); + + expect(session.exec('interact guitarist').ok).toBe(true); + drainConversation(session); + expect(session.observe().beat).toBe('concert_practiced'); + + walkTo(session, 0.42); + expect(session.exec('interact stage').ok).toBe(true); + drainConversation(session); + expect(session.observe().beat).toBe('concert_cleared'); + expect(session.observe().inventory).toContain('guitar'); + + walkTo(session, 0.9); + expect(session.exec('interact').ok).toBe(true); + drainConversation(session); +} + +function playDance(session: RidgeConsoleSession): void { + walkTo(session, 0.18); + expect(session.exec('interact traveler').ok).toBe(true); + drainConversation(session); + + walkTo(session, 0.48); + expect(session.exec('interact operations').ok).toBe(true); + drainConversation(session); + + walkTo(session, 0.42); + expect(session.exec('interact teacher').ok).toBe(true); + drainConversation(session); + expect(session.observe().beat).toBe('dance_ready'); + + walkTo(session, 0.35); + expect(session.exec('interact driver').ok).toBe(true); + drainConversation(session); + + walkTo(session, 0.62); + expect(session.exec('interact gate').ok).toBe(true); + drainConversation(session); + expect(session.observe().beat).toBe('dance_cleared'); + + walkTo(session, 0.88); + expect(session.exec('interact shuttle').ok).toBe(true); + drainConversation(session); +} + +function playRelay(session: RidgeConsoleSession): RidgeSessionEvent[] { + walkTo(session, 0.55); + expect(session.exec('interact').ok).toBe(true); + const events = drainConversation(session); + expect(session.observe().conversation?.awaitingChoice).toBe(true); + expect(session.exec('choose let-song-end').ok).toBe(true); + events.push(...drainConversation(session)); + return events; +} + function walkTo(session: RidgeConsoleSession, target: number): void { for (let i = 0; i < 40; i += 1) { const progress = session.observe().progress; @@ -100,8 +194,8 @@ function walkTo(session: RidgeConsoleSession, target: number): void { } function drainConversation(session: RidgeConsoleSession) { - const events = []; - for (let i = 0; i < 12; i += 1) { + const events: RidgeSessionEvent[] = []; + for (let i = 0; i < 20; i += 1) { const observation = session.observe(); if (observation.mode !== 'conversation') break; if (observation.conversation?.awaitingChoice) break; diff --git a/src/game/core/ridge/session.ts b/src/game/core/ridge/session.ts index 1463fbd..092aca9 100644 --- a/src/game/core/ridge/session.ts +++ b/src/game/core/ridge/session.ts @@ -9,20 +9,28 @@ import { import { getRidgeHelpText, parseRidgeCommand, parseRidgeScript } from './commands'; import { formatObservation, observeRidgeWorld } from './observe'; import type { - RidgeBridgeBeat, + RidgeAreaId, RidgeCommand, RidgeCommandResult, RidgeFacing, RidgeObservation, + RidgeRouteBeat, + RidgeSessionEvent, RidgeStageDefinition, + RidgeStageRegistry, RidgeWorldState } from './types'; +import { RIDGE_INITIAL_BEAT } from './types'; const DEFAULT_STEP = 0.05; export interface RidgeSessionOptions { - stage: RidgeStageDefinition; - beat?: RidgeBridgeBeat; + /** Preferred: full multi-area registry for Compact Area Transitions. */ + stages?: RidgeStageRegistry; + /** Legacy single-stage constructor (Bridge-only tests). */ + stage?: RidgeStageDefinition; + areaId?: RidgeAreaId; + beat?: RidgeRouteBeat; progress?: number; facing?: RidgeFacing; inventory?: readonly string[]; @@ -31,16 +39,27 @@ export interface RidgeSessionOptions { export class RidgeConsoleSession { private state: RidgeWorldState; - private readonly stage: RidgeStageDefinition; + private stage: RidgeStageDefinition; + private readonly stages: RidgeStageRegistry | null; constructor(options: RidgeSessionOptions) { - this.stage = options.stage; + if (options.stages) { + this.stages = options.stages; + const areaId = options.areaId ?? 'bridge'; + this.stage = options.stages[areaId]; + } else if (options.stage) { + this.stages = null; + this.stage = options.stage; + } else { + throw new Error('RidgeConsoleSession requires stages or stage.'); + } + this.state = { - areaId: options.stage.areaId, - title: options.stage.title, + areaId: this.stage.areaId, + title: this.stage.title, progress: clamp01(options.progress ?? 0.05), facing: options.facing ?? 'right', - beat: options.beat ?? 'intro', + beat: options.beat ?? RIDGE_INITIAL_BEAT[this.stage.areaId], mode: 'explore', flags: new Set(options.flags ?? []), inventory: [...(options.inventory ?? [])], @@ -113,16 +132,18 @@ export class RidgeConsoleSession { let blocked = false; const blockedAt = this.stage.blockedProgress; + const crossingOpen = this.stage.isCrossingOpen?.(this.state) ?? true; if ( blockedAt !== undefined && - this.state.beat !== 'bridge_complete' && - this.state.beat !== 'concert_handoff' && + !crossingOpen && direction === 'right' && nextProgress > blockedAt ) { nextProgress = blockedAt; blocked = true; - message = 'The unfinished bridge blocks the way. Talk to the draftsperson or finish the crossing.'; + message = + this.stage.blockedMessage ?? + 'Something blocks the way east. Talk to someone nearby.'; } this.state = { @@ -184,13 +205,7 @@ export class RidgeConsoleSession { return this.fail('No active conversation.'); } const result = advanceConversation(this.state); - this.state = result.state; - return { - ok: true, - message: result.message, - observation: this.observe(), - events: result.events - }; + return this.commitConversationResult(result); } private handleChoose(choiceIdOrIndex: string): RidgeCommandResult { @@ -198,23 +213,92 @@ export class RidgeConsoleSession { return this.fail('No active conversation.'); } const result = chooseInConversation(this.state, choiceIdOrIndex); + return this.commitConversationResult({ + ...result, + ok: !result.message.startsWith('Unknown') + }); + } + + private handleLeave(): RidgeCommandResult { + const result = leaveConversation(this.state); this.state = result.state; return { - ok: result.message.startsWith('Unknown') ? false : true, + ok: true, message: result.message, observation: this.observe(), events: result.events }; } - private handleLeave(): RidgeCommandResult { - const result = leaveConversation(this.state); + private commitConversationResult(result: { + state: RidgeWorldState; + events: RidgeSessionEvent[]; + message: string; + ok?: boolean; + }): RidgeCommandResult { this.state = result.state; + const events = [...result.events]; + + for (const event of result.events) { + if (event.type === 'area_handoff') { + this.applyAreaHandoff(event.areaId); + events.push({ type: 'beat_changed', beat: this.state.beat }); + } else if (event.type === 'route_reset') { + this.applyRouteReset(); + events.push({ type: 'beat_changed', beat: this.state.beat }); + events.push({ type: 'area_handoff', areaId: 'bridge' }); + } + } + return { - ok: true, + ok: result.ok ?? true, message: result.message, observation: this.observe(), - events: result.events + events + }; + } + + private applyAreaHandoff(areaId: RidgeAreaId): void { + if (!this.stages) { + this.state = { + ...this.state, + areaId, + beat: RIDGE_INITIAL_BEAT[areaId], + progress: 0.05, + facing: 'right', + lastMessage: `The page turns toward ${areaId}.` + }; + return; + } + + this.stage = this.stages[areaId]; + this.state = { + ...this.state, + areaId, + title: this.stage.title, + beat: RIDGE_INITIAL_BEAT[areaId], + progress: 0.05, + facing: 'right', + mode: 'explore', + conversation: null, + lastMessage: `Arrived: ${this.stage.title}` + }; + } + + private applyRouteReset(): void { + const bridge = this.stages?.bridge ?? this.stage; + this.stage = bridge; + this.state = { + areaId: 'bridge', + title: bridge.title, + progress: 0.05, + facing: 'right', + beat: 'intro', + mode: 'explore', + flags: new Set(), + inventory: [], + conversation: null, + lastMessage: 'For Cicka. The page begins again at the Bridge.' }; } diff --git a/src/game/core/ridge/types.ts b/src/game/core/ridge/types.ts index 7bcd5a9..e7cedcf 100644 --- a/src/game/core/ridge/types.ts +++ b/src/game/core/ridge/types.ts @@ -4,14 +4,32 @@ export type RidgeAreaId = 'bridge' | 'concert' | 'danceFestival' | 'relay'; -export type RidgeBridgeBeat = +/** Full first-playable route beat vocabulary across all areas. */ +export type RidgeRouteBeat = + // Bridge | 'intro' | 'needs_toy_car' | 'toy_car_shared' | 'bridge_complete' - | 'concert_handoff'; - -export type RidgeBridgeAreaBeat = Exclude; + // Concert + | 'concert_arrival' + | 'concert_practiced' + | 'concert_cleared' + // Dance Festival + | 'dance_arrival' + | 'dance_ready' + | 'dance_cleared' + // Relay Ending + | 'relay_linger' + | 'relay_farewell' + | 'relay_complete'; + +/** @deprecated Prefer RidgeRouteBeat — kept for Bridge-local call sites. */ +export type RidgeBridgeBeat = RidgeRouteBeat; +export type RidgeBridgeAreaBeat = Extract< + RidgeRouteBeat, + 'intro' | 'needs_toy_car' | 'toy_car_shared' | 'bridge_complete' +>; export type RidgeFacing = 'left' | 'right'; @@ -19,7 +37,21 @@ export type RidgeMode = 'explore' | 'conversation'; export type RidgeSpotKind = 'landmark' | 'npc' | 'prop' | 'exit'; -export type RidgeActorId = 'player' | 'cicka' | 'draftsperson' | 'toy-car'; +export type RidgeActorId = + | 'player' + | 'cicka' + | 'draftsperson' + | 'toy-car' + | 'guitarist' + | 'crowd' + | 'guitar' + | 'traveler' + | 'driver' + | 'operations-helper' + | 'dance-teacher' + | 'steward' + | 'counterpart-cat' + | 'shuttle'; export interface RidgeDialogueLine { id: string; @@ -37,9 +69,13 @@ export interface RidgeDialogueChoice { } export interface RidgeConversationOutcome { - setBeat?: RidgeBridgeAreaBeat; - concertHandoff?: boolean; + setBeat?: RidgeRouteBeat; + /** Swap into the next Compact Ridge Stage and reset progress. */ + handoffToArea?: RidgeAreaId; + /** First Playable Reset Return — clean Bridge restart. */ + routeReset?: boolean; addItem?: string; + removeItem?: string; setFlag?: string; clearFlag?: string; } @@ -96,6 +132,10 @@ export interface RidgeStageDefinition { spots: readonly RidgeSpotDefinition[]; /** Soft wall until the crossing opens. */ blockedProgress?: number; + /** Message when the soft wall stops the player. */ + blockedMessage?: string; + /** When true, blockedProgress no longer applies. */ + isCrossingOpen?: (state: RidgeWorldState) => boolean; resolveInteractables: (state: RidgeWorldState) => readonly RidgeInteractable[]; resolveActors: (state: RidgeWorldState) => readonly RidgeActorPresence[]; resolveConversation: ( @@ -105,12 +145,14 @@ export interface RidgeStageDefinition { describeAmbience: (state: RidgeWorldState) => string; } +export type RidgeStageRegistry = Record; + export interface RidgeWorldState { areaId: RidgeAreaId; title: string; progress: number; facing: RidgeFacing; - beat: RidgeBridgeBeat; + beat: RidgeRouteBeat; mode: RidgeMode; flags: ReadonlySet; inventory: readonly string[]; @@ -122,7 +164,7 @@ export interface RidgeObservation { mode: RidgeMode; areaId: RidgeAreaId; title: string; - beat: RidgeBridgeBeat; + beat: RidgeRouteBeat; progress: number; progressPercent: number; facing: RidgeFacing; @@ -160,11 +202,13 @@ export type RidgeCommand = | { type: 'unknown'; raw: string }; export type RidgeSessionEvent = - | { type: 'beat_changed'; beat: RidgeBridgeAreaBeat } - | { type: 'concert_handoff' } + | { type: 'beat_changed'; beat: RidgeRouteBeat } + | { type: 'area_handoff'; areaId: RidgeAreaId } + | { type: 'route_reset' } | { type: 'conversation_started'; conversationId: string } | { type: 'conversation_ended'; conversationId: string } | { type: 'item_added'; itemId: string } + | { type: 'item_removed'; itemId: string } | { type: 'flag_changed'; flag: string; present: boolean }; export interface RidgeCommandResult { @@ -173,3 +217,13 @@ export interface RidgeCommandResult { observation: RidgeObservation; events: readonly RidgeSessionEvent[]; } + +export const RIDGE_INITIAL_BEAT: Record = { + bridge: 'intro', + concert: 'concert_arrival', + danceFestival: 'dance_arrival', + relay: 'relay_linger' +}; + +export const RIDGE_GUITAR_ITEM = 'guitar'; +export const RIDGE_TOY_CAR_ITEM = 'toy-car'; diff --git a/src/game/scenes/basement/runtime/BasementScene.ts b/src/game/scenes/basement/runtime/BasementScene.ts index 627aded..19d84e1 100644 --- a/src/game/scenes/basement/runtime/BasementScene.ts +++ b/src/game/scenes/basement/runtime/BasementScene.ts @@ -25,7 +25,7 @@ import { type OpenOverlayOptions } from '@/game/bridge/store'; import { GameConsoleSession, type GameCommandResult } from '@/game/core/console'; -import { loadBridgeDialogueCatalog } from '@/game/scenes/ridge/content/bridgeCatalog'; +import { loadRidgeRouteDialogueCatalog } from '@/game/scenes/ridge/content/routeCatalog'; import { applyGameConsoleEvents } from '@/game/scenes/shared/applyGameConsoleEvents'; import { createUiText } from '@/game/sharedSceneRuntime/text/createUiText'; import { PlayerThoughtText } from '@/game/sharedSceneRuntime/text/PlayerThoughtText'; @@ -78,7 +78,7 @@ export class BasementScene extends Phaser.Scene { const messages = getMessages(); const bridge = bridgeStore.getState(); this.session = new GameConsoleSession({ - dialogue: loadBridgeDialogueCatalog(), + dialogue: loadRidgeRouteDialogueCatalog(), sceneId: 'basement', ownedItemIds: bridge.inventory.ownedItemIds, equippedItemIds: bridge.equipment.equippedItemIds, diff --git a/src/game/scenes/overworld/runtime/OverworldScene.ts b/src/game/scenes/overworld/runtime/OverworldScene.ts index 6afef60..747b5b3 100644 --- a/src/game/scenes/overworld/runtime/OverworldScene.ts +++ b/src/game/scenes/overworld/runtime/OverworldScene.ts @@ -39,7 +39,7 @@ import { type GameCommandResult, type NearbyThing } from '@/game/core/console'; -import { loadBridgeDialogueCatalog } from '@/game/scenes/ridge/content/bridgeCatalog'; +import { loadRidgeRouteDialogueCatalog } from '@/game/scenes/ridge/content/routeCatalog'; import { applyGameConsoleEvents } from '@/game/scenes/shared/applyGameConsoleEvents'; import { buildStreetEnvironment, @@ -130,7 +130,7 @@ export class OverworldScene extends Phaser.Scene { const messages = getMessages(); const bridge = bridgeStore.getState(); this.session = new GameConsoleSession({ - dialogue: loadBridgeDialogueCatalog(), + dialogue: loadRidgeRouteDialogueCatalog(), sceneId: 'overworld', ownedItemIds: bridge.inventory.ownedItemIds, equippedItemIds: bridge.equipment.equippedItemIds, diff --git a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts index bc16b18..f3bf6af 100644 --- a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts +++ b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts @@ -1,11 +1,21 @@ // Stick sync redraws many actors/layers each frame; branching is presentation policy. // fallow-ignore-file complexity import type * as Phaser from 'phaser'; +import type { RidgeAreaId } from '@/game/core/ridge'; import type { RidgeVisualProvider, RidgeVisualViewModel } from '../types'; import { drawStickCicka, + drawStickCrowd, + drawStickDanceTeacher, drawStickDraftsperson, + drawStickDriver, + drawStickGuitar, + drawStickGuitarist, + drawStickNpc, + drawStickOperationsHelper, drawStickPlayer, + drawStickShuttle, + drawStickSteward, drawStickToyCar } from './stickFigures'; @@ -71,7 +81,7 @@ export class StickVisualProvider implements RidgeVisualProvider { sync(view: RidgeVisualViewModel): void { if (this.destroyed) return; - this.drawBackground(view.bridgeOpen); + this.drawBackground(view.areaId, view.crossingOpen, view.beat); this.actors.clear(); for (const actor of view.actors) { @@ -83,7 +93,8 @@ export class StickVisualProvider implements RidgeVisualProvider { drawStickPlayer(this.actors, x, y, actor.facing, 1.15); break; case 'cicka': - drawStickCicka(this.actors, x, y, 1.1); + case 'counterpart-cat': + drawStickCicka(this.actors, x, y, actor.id === 'counterpart-cat' ? 0.95 : 1.1); break; case 'draftsperson': drawStickDraftsperson(this.actors, x, y, actor.facing, 1.05); @@ -91,6 +102,33 @@ export class StickVisualProvider implements RidgeVisualProvider { case 'toy-car': drawStickToyCar(this.actors, x + 18, y + 4, 1.1); break; + case 'guitarist': + drawStickGuitarist(this.actors, x, y, actor.facing, 1.05); + break; + case 'crowd': + drawStickCrowd(this.actors, x, y, 1); + break; + case 'guitar': + drawStickGuitar(this.actors, x + 16, y + 2, 1.1); + break; + case 'traveler': + drawStickNpc(this.actors, x, y, actor.facing, 1); + break; + case 'driver': + drawStickDriver(this.actors, x, y, actor.facing, 1.05); + break; + case 'operations-helper': + drawStickOperationsHelper(this.actors, x, y, actor.facing, 1.05); + break; + case 'dance-teacher': + drawStickDanceTeacher(this.actors, x, y, actor.facing, 1.05); + break; + case 'steward': + drawStickSteward(this.actors, x, y, actor.facing, 1); + break; + case 'shuttle': + drawStickShuttle(this.actors, x, y, 1.1); + break; } } @@ -119,27 +157,40 @@ export class StickVisualProvider implements RidgeVisualProvider { this.promptText = undefined; } - private drawBackground(bridgeOpen: boolean): void { + private drawBackground( + areaId: RidgeAreaId, + crossingOpen: boolean, + beat: RidgeVisualViewModel['beat'] + ): void { const g = this.background; g.clear(); - - // paper wash g.fillStyle(PAPER, 1); g.fillRect(0, 0, this.stageWidth, this.stageHeight); - // faint ruled lines g.lineStyle(1, FAINT, 0.08); for (let y = 40; y < this.stageHeight; y += 36) { g.lineBetween(0, y, this.stageWidth, y); } - // sky hatch band + g.lineStyle(3, INK, 1); + g.lineBetween(0, GROUND_Y, this.stageWidth, GROUND_Y); + + if (areaId === 'bridge') { + this.drawBridgeSet(g, crossingOpen); + } else if (areaId === 'concert') { + this.drawConcertSet(g, crossingOpen); + } else if (areaId === 'danceFestival') { + this.drawDanceSet(g, crossingOpen); + } else { + this.drawRelaySet(g, beat); + } + } + + private drawBridgeSet(g: Phaser.GameObjects.Graphics, bridgeOpen: boolean): void { g.lineStyle(1, INK, 0.12); for (let x = 0; x < this.stageWidth; x += 28) { g.lineBetween(x, 40, x + 18, 90); } - - // far hills g.lineStyle(2, INK, 0.35); g.beginPath(); g.moveTo(0, 280); @@ -151,17 +202,14 @@ export class StickVisualProvider implements RidgeVisualProvider { g.lineTo(this.stageWidth, 240); g.strokePath(); - // cornfield sticks (left) g.lineStyle(2, INK, 0.7); for (let i = 0; i < 26; i += 1) { const x = 90 + i * 18; const h = 55 + ((i * 17) % 35); g.lineBetween(x, GROUND_Y, x, GROUND_Y - h); g.lineBetween(x, GROUND_Y - h, x + 8, GROUND_Y - h - 10); - g.lineBetween(x, GROUND_Y - h, x - 7, GROUND_Y - h - 8); } - // river const riverLeft = this.worldXForProgress(0.58); const riverRight = this.worldXForProgress(0.78); g.lineStyle(2, INK, 0.45); @@ -174,45 +222,96 @@ export class StickVisualProvider implements RidgeVisualProvider { g.strokePath(); } - // banks - g.lineStyle(3, INK, 1); - g.lineBetween(0, GROUND_Y, riverLeft, GROUND_Y); - g.lineBetween(riverRight, GROUND_Y, this.stageWidth, GROUND_Y); - g.lineBetween(riverLeft, GROUND_Y, riverLeft, GROUND_Y + 64); - g.lineBetween(riverRight, GROUND_Y, riverRight, GROUND_Y + 64); - - // bridge g.lineStyle(4, INK, 1); if (bridgeOpen) { g.lineBetween(riverLeft, GROUND_Y - 4, riverRight, GROUND_Y - 4); - g.lineBetween(riverLeft + 20, GROUND_Y - 4, riverLeft + 40, GROUND_Y - 28); - g.lineBetween(riverRight - 20, GROUND_Y - 4, riverRight - 40, GROUND_Y - 28); - g.lineBetween(riverLeft + 40, GROUND_Y - 28, riverRight - 40, GROUND_Y - 28); } else { - // unfinished: gap in the middle with dashed intent const mid = (riverLeft + riverRight) / 2; g.lineBetween(riverLeft, GROUND_Y - 4, mid - 36, GROUND_Y - 4); g.lineBetween(mid + 36, GROUND_Y - 4, riverRight, GROUND_Y - 4); g.lineStyle(2, INK, 0.4); g.lineBetween(mid - 30, GROUND_Y - 18, mid + 30, GROUND_Y - 18); - // blueprint board - g.lineStyle(2, INK, 0.9); g.strokeRect(mid - 42, GROUND_Y - 90, 84, 50); - g.lineBetween(mid - 28, GROUND_Y - 65, mid - 8, GROUND_Y - 65); - g.lineBetween(mid + 8, GROUND_Y - 65, mid + 28, GROUND_Y - 65); } - // sun scribble g.lineStyle(2, INK, 0.5); g.strokeCircle(140, 110, 28); + } + + private drawConcertSet(g: Phaser.GameObjects.Graphics, crossingOpen: boolean): void { + // night wash hatch + g.lineStyle(1, INK, 0.18); + for (let x = 0; x < this.stageWidth; x += 22) { + g.lineBetween(x, 20, x + 10, 120); + } + // storefronts + g.lineStyle(2.5, INK, 0.85); + for (let i = 0; i < 6; i += 1) { + const x = 120 + i * 220; + g.strokeRect(x, GROUND_Y - 160, 140, 160); + g.strokeRect(x + 20, GROUND_Y - 100, 40, 50); + g.strokeRect(x + 80, GROUND_Y - 100, 40, 50); + } + // crossing / crowd lane mark + const gate = this.worldXForProgress(0.55); + g.lineStyle(3, INK, crossingOpen ? 0.25 : 0.9); + g.lineBetween(gate, GROUND_Y - 8, gate, GROUND_Y - 70); + if (!crossingOpen) { + g.lineBetween(gate - 40, GROUND_Y - 40, gate + 40, GROUND_Y - 40); + } + // moon + g.lineStyle(2, INK, 0.55); + g.strokeCircle(this.stageWidth - 160, 100, 26); + } + + private drawDanceSet(g: Phaser.GameObjects.Graphics, crossingOpen: boolean): void { + // daytime warm hatch + g.lineStyle(1, INK, 0.1); + for (let x = 0; x < this.stageWidth; x += 30) { + g.lineBetween(x, 30, x + 16, 100); + } + // lantern posts + g.lineStyle(2, INK, 0.8); for (let i = 0; i < 8; i += 1) { - const a = (Math.PI * 2 * i) / 8; - g.lineBetween( - 140 + Math.cos(a) * 34, - 110 + Math.sin(a) * 34, - 140 + Math.cos(a) * 48, - 110 + Math.sin(a) * 48 - ); + const x = 160 + i * 170; + g.lineBetween(x, GROUND_Y, x, GROUND_Y - 90); + g.strokeRect(x - 8, GROUND_Y - 110, 16, 20); + } + // service gate + const gate = this.worldXForProgress(0.62); + g.lineStyle(3, INK, crossingOpen ? 0.25 : 1); + g.strokeRect(gate - 30, GROUND_Y - 80, 60, 80); + if (crossingOpen) { + g.lineStyle(2, INK, 0.4); + g.lineBetween(gate + 30, GROUND_Y - 80, gate + 70, GROUND_Y - 40); } + // dance floor edge + g.lineStyle(2, INK, 0.35); + g.strokeEllipse(this.worldXForProgress(0.45), GROUND_Y - 20, 120, 30); + } + + private drawRelaySet( + g: Phaser.GameObjects.Graphics, + beat: RidgeVisualViewModel['beat'] + ): void { + // sunset arcs + g.lineStyle(2, INK, 0.35); + for (let i = 0; i < 5; i += 1) { + g.strokeCircle(this.stageWidth * 0.7, 180, 40 + i * 28); + } + // overlook ledge + g.lineStyle(3, INK, 1); + g.lineBetween(this.worldXForProgress(0.15), GROUND_Y, this.worldXForProgress(0.9), GROUND_Y); + g.lineBetween( + this.worldXForProgress(0.85), + GROUND_Y, + this.worldXForProgress(0.95), + GROUND_Y + 40 + ); + // warm threshold seam + const tx = this.worldXForProgress(0.85); + g.lineStyle(2, INK, beat === 'relay_complete' ? 0.2 : 0.7); + g.strokeCircle(tx, GROUND_Y - 70, 34); + g.lineBetween(tx - 20, GROUND_Y - 70, tx + 20, GROUND_Y - 70); } } diff --git a/src/game/scenes/ridge/art/stick/stickFigures.ts b/src/game/scenes/ridge/art/stick/stickFigures.ts index 3524b5a..672df42 100644 --- a/src/game/scenes/ridge/art/stick/stickFigures.ts +++ b/src/game/scenes/ridge/art/stick/stickFigures.ts @@ -73,20 +73,10 @@ export function drawStickDraftsperson( facing: RidgeFacing, scale = 1 ): void { + drawStickPerson(g, x, y, facing, scale * 1.05, 'clipboard'); const s = 17 * scale; const dir = facing === 'left' ? -1 : 1; - g.lineStyle(3, INK, 1); - g.fillStyle(PAPER, 1); - - g.fillCircle(x, y - s * 1.65, s * 0.4); - g.strokeCircle(x, y - s * 1.65, s * 0.4); - g.lineBetween(x, y - s * 1.25, x, y - s * 0.15); - g.lineBetween(x, y - s * 0.95, x + dir * s * 0.75, y - s * 0.7); - g.lineBetween(x, y - s * 0.95, x - dir * s * 0.45, y - s * 0.55); - g.lineBetween(x, y - s * 0.15, x - s * 0.35, y + s * 0.55); - g.lineBetween(x, y - s * 0.15, x + s * 0.35, y + s * 0.55); - - // blueprint board + // blueprint board (wider than clipboard prop) g.strokeRect(x + dir * s * 0.55, y - s * 0.95, s * 0.7, s * 0.5); g.lineBetween( x + dir * s * 0.65, @@ -110,3 +100,140 @@ export function drawStickToyCar( g.strokeCircle(x - s * 0.55, y + s * 0.25, s * 0.28); g.strokeCircle(x + s * 0.55, y + s * 0.25, s * 0.28); } + +function drawStickPerson( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale: number, + prop?: 'guitar' | 'clipboard' | 'lantern' +): void { + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + g.lineStyle(3, INK, 1); + g.fillStyle(PAPER, 1); + g.fillCircle(x, y - s * 1.65, s * 0.4); + g.strokeCircle(x, y - s * 1.65, s * 0.4); + g.lineBetween(x, y - s * 1.25, x, y - s * 0.15); + g.lineBetween(x, y - s * 0.95, x + dir * s * 0.7, y - s * 0.55); + g.lineBetween(x, y - s * 0.95, x - dir * s * 0.5, y - s * 0.5); + g.lineBetween(x, y - s * 0.15, x - s * 0.35, y + s * 0.55); + g.lineBetween(x, y - s * 0.15, x + s * 0.35, y + s * 0.55); + + if (prop === 'guitar') { + g.strokeEllipse(x + dir * s * 0.55, y - s * 0.55, s * 0.55, s * 0.85); + g.lineBetween(x + dir * s * 0.55, y - s * 1.0, x + dir * s * 0.55, y - s * 1.35); + } else if (prop === 'clipboard') { + g.strokeRect(x + dir * s * 0.45, y - s * 0.95, s * 0.45, s * 0.6); + } else if (prop === 'lantern') { + g.strokeRect(x + dir * s * 0.55, y - s * 0.85, s * 0.35, s * 0.45); + g.lineBetween(x + dir * s * 0.72, y - s * 0.85, x + dir * s * 0.72, y - s * 1.1); + } +} + +export function drawStickGuitarist( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale = 1 +): void { + drawStickPerson(g, x, y, facing, scale, 'guitar'); + // tiny wrist wrap + g.lineStyle(2, INK, 0.8); + g.lineBetween(x + 8, y - 18 * scale, x + 14, y - 14 * scale); +} + +export function drawStickCrowd( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + scale = 1 +): void { + drawStickPerson(g, x - 14 * scale, y, 'left', scale * 0.85); + drawStickPerson(g, x, y, 'right', scale * 0.9); + drawStickPerson(g, x + 16 * scale, y, 'left', scale * 0.8); +} + +export function drawStickGuitar( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + scale = 1 +): void { + const s = 10 * scale; + g.lineStyle(2.5, INK, 1); + g.strokeEllipse(x, y - s * 0.2, s * 0.7, s); + g.lineBetween(x, y - s * 0.8, x, y - s * 1.5); + g.strokeRect(x - s * 0.15, y - s * 1.65, s * 0.3, s * 0.25); +} + +export function drawStickNpc( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale = 1 +): void { + drawStickPerson(g, x, y, facing, scale); +} + +export function drawStickDriver( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale = 1 +): void { + drawStickPerson(g, x, y, facing, scale, 'clipboard'); +} + +export function drawStickOperationsHelper( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale = 1 +): void { + drawStickPerson(g, x, y, facing, scale, 'lantern'); +} + +export function drawStickDanceTeacher( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale = 1 +): void { + drawStickPerson(g, x, y, facing, scale * 1.05); + g.lineStyle(2, INK, 0.5); + g.strokeCircle(x, y - 28 * scale, 22 * scale); +} + +export function drawStickSteward( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale = 1 +): void { + drawStickPerson(g, x, y, facing, scale); + g.strokeRect(x - 6 * scale, y - 38 * scale, 12 * scale, 8 * scale); +} + +export function drawStickShuttle( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + scale = 1 +): void { + const s = 14 * scale; + g.lineStyle(2.5, INK, 1); + g.fillStyle(PAPER, 1); + g.fillRect(x - s * 1.6, y - s, s * 3.2, s * 0.9); + g.strokeRect(x - s * 1.6, y - s, s * 3.2, s * 0.9); + g.strokeCircle(x - s, y + s * 0.1, s * 0.28); + g.strokeCircle(x + s, y + s * 0.1, s * 0.28); + g.strokeRect(x - s * 1.3, y - s * 0.75, s * 0.7, s * 0.4); +} diff --git a/src/game/scenes/ridge/art/types.ts b/src/game/scenes/ridge/art/types.ts index 81aa6ee..f3daec1 100644 --- a/src/game/scenes/ridge/art/types.ts +++ b/src/game/scenes/ridge/art/types.ts @@ -1,5 +1,6 @@ import type { RidgeActorPresence, + RidgeAreaId, RidgeMode, RidgeObservation } from '@/game/core/ridge'; @@ -10,13 +11,14 @@ import type { */ export interface RidgeVisualViewModel { mode: RidgeMode; + areaId: RidgeAreaId; progress: number; facing: RidgeObservation['facing']; beat: RidgeObservation['beat']; ambience: string; nearbyPrompt: string | null; actors: readonly RidgeActorPresence[]; - bridgeOpen: boolean; + crossingOpen: boolean; } export interface RidgeVisualProvider { @@ -28,16 +30,20 @@ export interface RidgeVisualProvider { export function toRidgeVisualViewModel( observation: RidgeObservation ): RidgeVisualViewModel { + const crossingOpen = + observation.beat === 'bridge_complete' || + observation.beat === 'concert_cleared' || + observation.beat === 'dance_cleared'; + return { mode: observation.mode, + areaId: observation.areaId, progress: observation.progress, facing: observation.facing, beat: observation.beat, ambience: observation.ambience, nearbyPrompt: observation.nearby[0]?.prompt ?? null, actors: observation.actors, - bridgeOpen: - observation.beat === 'bridge_complete' || - observation.beat === 'concert_handoff' + crossingOpen }; } diff --git a/src/game/scenes/ridge/content/bridgeCatalog.ts b/src/game/scenes/ridge/content/bridgeCatalog.ts deleted file mode 100644 index e8f4f19..0000000 --- a/src/game/scenes/ridge/content/bridgeCatalog.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { BridgeDialogueCatalog } from '@/game/core/ridge'; -import { getMessages } from '@/shared/i18n'; - -/** Wires i18n copy into the pure Bridge stage catalog. */ -export function loadBridgeDialogueCatalog(): BridgeDialogueCatalog { - const bridge = getMessages().scenes.ridge.bridge; - return { - speakers: { - prompt: bridge.speakers.prompt, - cicka: bridge.speakers.cicka, - bridgeDraftsperson: bridge.speakers.bridgeDraftsperson - }, - lines: { ...bridge.dialogue } - }; -} diff --git a/src/game/scenes/ridge/content/routeCatalog.ts b/src/game/scenes/ridge/content/routeCatalog.ts new file mode 100644 index 0000000..bb044b4 --- /dev/null +++ b/src/game/scenes/ridge/content/routeCatalog.ts @@ -0,0 +1,10 @@ +import { + loadRouteCatalogFromMessages, + type RidgeRouteDialogueCatalog +} from '@/game/core/ridge'; +import { getMessages } from '@/shared/i18n'; + +/** Wires i18n copy into the full first-playable Ridge route catalog. */ +export function loadRidgeRouteDialogueCatalog(): RidgeRouteDialogueCatalog { + return loadRouteCatalogFromMessages(getMessages().scenes.ridge); +} diff --git a/src/game/scenes/ridge/runtime/RidgeScene.ts b/src/game/scenes/ridge/runtime/RidgeScene.ts index c796c8e..ee32fbb 100644 --- a/src/game/scenes/ridge/runtime/RidgeScene.ts +++ b/src/game/scenes/ridge/runtime/RidgeScene.ts @@ -2,11 +2,15 @@ import * as Phaser from 'phaser'; import { bridgeActions, bridgeStore, - type OpenOverlayOptions + type OpenOverlayOptions, + type RidgeRouteBeatState } from '@/game/bridge/store'; import { - createBridgeStage, + createRidgeRouteStages, RidgeConsoleSession, + RIDGE_GUITAR_ITEM, + RIDGE_INITIAL_BEAT, + type RidgeAreaId, type RidgeCommandResult, type RidgeSessionEvent } from '@/game/core/ridge'; @@ -23,7 +27,7 @@ import { import { bindSideViewKeyboard } from '@/game/sharedSceneRuntime/input/sceneKeyboard'; import { StickVisualProvider } from '../art/stick/StickVisualProvider'; import { toRidgeVisualViewModel } from '../art/types'; -import { loadBridgeDialogueCatalog } from '../content/bridgeCatalog'; +import { loadRidgeRouteDialogueCatalog } from '../content/routeCatalog'; import type { RidgeConversationPanelView } from '../sceneUi/RidgeConversationPanel'; import type { RidgeDevControls } from './ridgeDevControls'; @@ -63,13 +67,18 @@ export class RidgeScene extends Phaser.Scene { create(): void { const route = bridgeStore.getState().progress.ridge.firstPlayableRoute; - const beat = - route.activeAreaId === 'bridge' ? route.bridgeBeat : 'concert_handoff'; + const areaId = route.activeAreaId; + const hasGuitar = + route.beat === 'concert_cleared' || + areaId === 'danceFestival' || + areaId === 'relay'; this.session = new RidgeConsoleSession({ - stage: createBridgeStage(loadBridgeDialogueCatalog()), - beat, - progress: 0.05 + stages: createRidgeRouteStages(loadRidgeRouteDialogueCatalog()), + areaId, + beat: route.beat, + progress: 0.05, + inventory: hasGuitar ? [RIDGE_GUITAR_ITEM] : [] }); this.visuals = new StickVisualProvider(this); @@ -166,11 +175,16 @@ export class RidgeScene extends Phaser.Scene { private handleSessionEvent(event: RidgeSessionEvent): void { if (event.type === 'beat_changed') { - bridgeActions.setRidgeBridgeBeat(event.beat); + bridgeActions.setRidgeRouteBeat(event.beat as RidgeRouteBeatState); return; } - if (event.type === 'concert_handoff') { - bridgeActions.triggerRidgeConcertHandoff(); + if (event.type === 'area_handoff') { + const areaId = event.areaId as RidgeAreaId; + bridgeActions.setRidgeAreaHandoff(areaId, RIDGE_INITIAL_BEAT[areaId] as RidgeRouteBeatState); + return; + } + if (event.type === 'route_reset') { + bridgeActions.resetRidgeFirstPlayableRoute(); } } @@ -239,7 +253,9 @@ function portraitForSpeaker( speakerId: string ): RidgeConversationPanelView['portrait'] { if (speakerId === 'cicka') return 'cicka'; - if (speakerId === 'bridgeDraftsperson') return 'draftsperson'; - if (speakerId === 'prompt') return 'prompt'; + if (speakerId === 'bridgeDraftsperson' || speakerId === 'injuredGuitarist') { + return 'draftsperson'; + } + if (speakerId === 'prompt' || speakerId === 'dedication') return 'prompt'; return 'player'; } diff --git a/src/game/scenes/ridge/runtime/ridgeDevControls.ts b/src/game/scenes/ridge/runtime/ridgeDevControls.ts index 3615c52..63ad22d 100644 --- a/src/game/scenes/ridge/runtime/ridgeDevControls.ts +++ b/src/game/scenes/ridge/runtime/ridgeDevControls.ts @@ -1,9 +1,9 @@ -import type { RidgeBridgeBeatState } from '@/game/bridge/store'; +import type { RidgeRouteBeatState } from '@/game/bridge/store'; /** Lightweight Ridge DEV readouts for the stick/console rebuild. */ export interface RidgeDevPlayerSnapshot { progress: number; - beat: RidgeBridgeBeatState; + beat: RidgeRouteBeatState; mode: 'explore' | 'conversation'; nearby: string[]; } diff --git a/src/shared/i18n/messages/en/scenes.ts b/src/shared/i18n/messages/en/scenes.ts index b80cfc3..e99f628 100644 --- a/src/shared/i18n/messages/en/scenes.ts +++ b/src/shared/i18n/messages/en/scenes.ts @@ -72,6 +72,138 @@ export const sceneMessages = { }, handoffNote: "evening music ahead", }, + concert: { + speakers: { + prompt: "Prompt", + cicka: "Cicka", + injuredGuitarist: "Injured Guitarist", + crowd: "Crowd", + }, + dialogue: { + "concert.crowd.delay_barks.01": "Show's late. Crossing's full of patience wearing thin.", + "concert.crowd.delay_barks.02": "Heard the guitarist wiped out trying to look brave.", + "concert.crowd.delay_barks.03": "Someone's behind the stage props. Maybe start there.", + "concert.guitarist.injury.01": "I tried a one-leg skateboard solo. The street voted no.", + "concert.guitarist.injury.02": "Wrist won't play loud. Pride won't either.", + "concert.guitarist.injury.03": "Learn the phrase with me", + "concert.guitarist.practice_riff.01": "Practice the forgiving riff", + "concert.guitarist.practice_riff.02": "You find the phrase without failing.", + "concert.guitarist.practice_riff.03": "That much courage can clear a street.", + "concert.performance.auto_success.01": "Start the concert", + "concert.performance.auto_success.02": "The phrase lands. Soft. True.", + "concert.performance.auto_success.03": "Alright—show happened. People can move.", + "concert.performance.auto_success.04": "Take the guitar. Carry the comfort.", + "concert.guitarist.guitar_handoff.01": "Keep it for the road ahead.", + "concert.guitarist.guitar_handoff.02": "Play it when quiet needs company.", + "concert.guitarist.guitar_handoff.03": "The guitar rests warm against your side.", + "concert.cicka.band_resting_spot.01": "Sit near hidden Cicka", + "concert.cicka.band_resting_spot.02": "mrrp.", + "concert.cicka.band_resting_spot.03": "Cicka loafs with the band", + "concert.cicka.band_resting_spot.04": "purr.", + "concert.exit.dance_transition.01": "Follow the opened crossing", + "concert.exit.dance_transition.02": "Festival setup waits downhill.", + "concert.exit.dance_transition.03": "The page warms toward afternoon.", + }, + }, + dance: { + speakers: { + prompt: "Prompt", + cicka: "Cicka", + traveler: "Traveler", + hillShuttleDriver: "Hill-Shuttle Driver", + operationsHelper: "Last-Stop Operations Helper", + danceTeacher: "Dance Teacher", + festivalSteward: "Festival Steward", + }, + dialogue: { + "dance.traveler.relay_wayfinding.01": "Relay is up the hill shuttle.", + "dance.traveler.relay_wayfinding.02": + "Last daylight ride—only after setup clears.", + "dance.traveler.relay_wayfinding.03": + "Ask at the shuttle, then the operations table.", + "dance.driver.shuttle_delay.01": + "Can't leave until the steward opens the gate.", + "dance.driver.shuttle_delay.02": + "Clipboard says ready. My feet disagree.", + "dance.driver.shuttle_delay.03": "He keeps rereading the same safe line.", + "dance.driver.shuttle_delay.choice.help": "One step. Privately. Maybe.", + "dance.driver.shuttle_delay.choice.wait": + "You give him space and ask around.", + "dance.operations_helper.handoff_check.01": + "If the lantern line fails, I fail with it.", + "dance.operations_helper.handoff_check.02": + "Check the details with me once.", + "dance.operations_helper.handoff_check.03": + "Help with the operations handoff", + "dance.operations_helper.handoff_check.done.01": + "The teacher can keep watch now.", + "dance.operations_helper.handoff_check.done.02": + "She almost believes the plaza can breathe.", + "dance.locals.triangulated_read.01": "Road opens after the setup check.", + "dance.locals.triangulated_read.02": + "She waits on one perfect lantern. He waits on anything but asking.", + "dance.locals.triangulated_read.03": + "They practice the art of being unavailable.", + "dance.driver.one_step_practice.01": "One private step. No audience.", + "dance.driver.one_step_practice.02": + "He learns exactly one shared rhythm.", + "dance.driver.one_step_practice.03": + "Okay. I can offer that much later.", + "dance.driver.one_step_practice.done.01": "He has his one step.", + "dance.driver.one_step_practice.done.02": "Dignity intact.", + "dance.driver.folded_song_request.01": + "Help me fold a tiny song request.", + "dance.driver.folded_song_request.02": + "No confession. Just one dance later.", + "dance.driver.folded_song_request.03": + "She reads it. Soft nod. No spotlight.", + "dance.driver.folded_song_request.04": "The practical courage lands.", + "dance.setup_clearance.01": "Help with remaining preparations", + "dance.setup_clearance.02": "Secure the lantern line.", + "dance.setup_clearance.03": "Tape the service lane clear.", + "dance.setup_clearance.04": "Gate open. Last daylight window.", + "dance.setup_clearance.05": "Flip the shuttle sign.", + "dance.shuttle.last_daylight_ride.01": "All aboard the last ride.", + "dance.shuttle.last_daylight_ride.02": + "The hill lifts into sunset paper.", + "dance.shuttle.last_daylight_ride.03": "Relay waits quiet above.", + "dance.cicka.resting_spot.01": "Cicka loafs on the operations table", + "dance.cicka.resting_spot.02": "Cicka settles by the cleared gate", + "dance.cicka.resting_spot.03": "mrrp.", + }, + }, + relay: { + speakers: { + prompt: "Prompt", + cicka: "Cicka", + dedication: "Dedication", + }, + dialogue: { + "relay.overlook.inspect.01": "Look out over the finished route", + "relay.overlook.inspect.02": + "The pages below still hold their changes.", + "relay.sit_and_play.prompt.01": "Sit and play beside Cicka", + "relay.sit_and_play.prompt.02": + "You settle. The guitar finds the concert phrase.", + "relay.sit_and_play.prompt.03": "The overlook softens.", + "relay.montage.bridge.01": "(memory) The finished bridge holds.", + "relay.montage.concert.01": + "(memory) The crossing clears; the guitar changes hands.", + "relay.montage.dance.01": + "(memory) Night lanterns wake after you leave.", + "relay.guitar.sunset.01": + "Sunset lowers while the phrase keeps breathing.", + "relay.guitar.let_song_end.01": "Let the song end", + "relay.guitar.let_song_end.02": "The phrase resolves into quiet.", + "relay.cicka.threshold_meow.01": "mrrp.", + "relay.cicka.threshold_meow.02": + "Cicka turns back once, then slips into warm paper light.", + "relay.cicka.threshold_meow.03": + "The overlook holds empty for a breath.", + "relay.dedication.card.01": "For Cicka.", + "relay.dedication.card.02": "Thank you for playing.", + }, + }, }, stampedeSketch: { result: { From 1c1f3133b8b5abe339438fa23c42ed07e8550453 Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Thu, 30 Jul 2026 22:44:13 +0200 Subject: [PATCH 2/4] fix(ridge): unstick Dance Festival progression + cast readability Nearest interact now sorts by distance so the service gate wins over the steward at the soft wall. Plaza cast is spread out with distinct stick silhouettes, name tags, and clearer next-step prompts. Co-authored-by: Cursor --- src/game/core/ridge/content/danceStage.ts | 176 +++++++++-------- .../core/ridge/content/testRouteCatalog.ts | 33 ++-- src/game/core/ridge/observe.ts | 6 +- src/game/core/ridge/session.test.ts | 18 +- src/game/core/ridge/session.ts | 9 +- src/game/core/ridge/types.ts | 2 +- .../ridge/art/stick/StickVisualProvider.ts | 54 +++++- .../scenes/ridge/art/stick/stickFigures.ts | 181 ++++++++++++------ src/shared/i18n/messages/en/scenes.ts | 42 ++-- 9 files changed, 327 insertions(+), 194 deletions(-) diff --git a/src/game/core/ridge/content/danceStage.ts b/src/game/core/ridge/content/danceStage.ts index cf7d716..47ac2f9 100644 --- a/src/game/core/ridge/content/danceStage.ts +++ b/src/game/core/ridge/content/danceStage.ts @@ -9,7 +9,7 @@ import type { } from '../types'; import { collectNearbyFromPlans, makeCatalogLine } from './dialogueHelpers'; -const DANCE_BLOCKED_PROGRESS = 0.62; +const DANCE_BLOCKED_PROGRESS = 0.68; const FLAG_OPS = 'ops_ready'; const FLAG_DRIVER = 'driver_ready'; @@ -26,6 +26,10 @@ export interface DanceDialogueCatalog { lines: Record; } +/** + * Spread plaza cast left→right so silhouettes and prompts stay readable. + * Soft wall / service gate sits at ~0.68; shuttle beyond after clearance. + */ const SPOTS = [ { id: 'entry', @@ -39,8 +43,8 @@ const SPOTS = [ id: 'traveler', label: 'Traveler', kind: 'npc' as const, - progress: 0.18, - interactRadius: 0.07, + progress: 0.14, + interactRadius: 0.08, actorId: 'traveler' as const, description: 'Someone asking the same question you are: how to reach Relay.' }, @@ -48,61 +52,61 @@ const SPOTS = [ id: 'driver', label: 'Hill-Shuttle Driver', kind: 'npc' as const, - progress: 0.35, - interactRadius: 0.08, + progress: 0.28, + interactRadius: 0.09, actorId: 'driver' as const, - description: 'Stuck on a route clipboard beside the last-daylight shuttle.' - }, - { - id: 'operations', - label: 'Last-Stop Operations Helper', - kind: 'npc' as const, - progress: 0.48, - interactRadius: 0.08, - actorId: 'operations-helper' as const, - description: 'Perfecting lanterns and chair stacks near the dance floor edge.' + description: 'Stuck on a route clipboard beside the shuttle sign.' }, { id: 'teacher', label: 'Dance Teacher', kind: 'npc' as const, - progress: 0.42, - interactRadius: 0.07, + progress: 0.4, + interactRadius: 0.09, actorId: 'dance-teacher' as const, description: 'Keeps watch without forcing anyone onto the floor.' }, - { - id: 'steward', - label: 'Festival Steward', - kind: 'npc' as const, - progress: 0.58, - interactRadius: 0.07, - actorId: 'steward' as const, - description: 'Holds the service-gate key until setup is safe.' - }, { id: 'cicka', label: 'Cicka', kind: 'npc' as const, - progress: 0.52, - interactRadius: 0.06, + progress: 0.48, + interactRadius: 0.07, actorId: 'cicka' as const, description: 'Loafs near the operations table like a soft paperweight.' }, + { + id: 'operations', + label: 'Last-Stop Operations Helper', + kind: 'npc' as const, + progress: 0.56, + interactRadius: 0.09, + actorId: 'operations-helper' as const, + description: 'Perfecting lanterns and chair stacks near the dance floor edge.' + }, + { + id: 'steward', + label: 'Festival Steward', + kind: 'npc' as const, + progress: 0.64, + interactRadius: 0.08, + actorId: 'steward' as const, + description: 'Holds the service-gate key until setup is safe.' + }, { id: 'gate', label: 'Service gate', kind: 'prop' as const, - progress: 0.62, - interactRadius: 0.07, + progress: 0.68, + interactRadius: 0.1, description: 'Festival barriers and cable tape block the hill road.' }, { id: 'shuttle', label: 'Last daylight shuttle', kind: 'exit' as const, - progress: 0.88, - interactRadius: 0.08, + progress: 0.9, + interactRadius: 0.1, actorId: 'shuttle' as const, description: 'The threshold ride to Relay under warming sky.' } @@ -115,8 +119,7 @@ export function createDanceStage(catalog: DanceDialogueCatalog): RidgeStageDefin lengthLabel: 'Last-Stop Plaza', spots: SPOTS, blockedProgress: DANCE_BLOCKED_PROGRESS, - blockedMessage: - 'The service road is closed for festival setup. Help the plaza become ready.', + blockedMessage: (state) => describeDanceBlock(state), isCrossingOpen: (state) => state.beat === 'dance_cleared', resolveInteractables: (state) => resolveDanceInteractables(state, catalog), resolveActors: (state) => resolveDanceActors(state), @@ -130,11 +133,25 @@ function bothReady(state: RidgeWorldState): boolean { return state.flags.has(FLAG_OPS) && state.flags.has(FLAG_DRIVER); } +function describeDanceBlock(state: RidgeWorldState): string { + if (state.beat === 'dance_ready' || bothReady(state)) { + return 'Service gate is ready. Press interact on the gate to clear the last setup.'; + } + if (state.flags.has(FLAG_OPS) && !state.flags.has(FLAG_DRIVER)) { + return 'Operations is ready. Ask the Dance Teacher for one private practice step.'; + } + if (state.flags.has(FLAG_DRIVER) && !state.flags.has(FLAG_OPS)) { + return 'The driver has one step. Help the Operations Helper finish her handoff check.'; + } + return 'Service road closed for festival setup. Help Operations and the Dance Teacher first.'; +} + function resolveDanceInteractables( state: RidgeWorldState, catalog: DanceDialogueCatalog ): RidgeInteractable[] { const plans: { spotId: string; conversationId: string; prompt: string }[] = []; + const line = (key: string) => catalog.lines[key] ?? key; if (state.beat === 'dance_arrival' || state.beat === 'dance_ready') { plans.push({ @@ -149,17 +166,17 @@ function resolveDanceInteractables( }); plans.push({ spotId: 'teacher', - conversationId: state.flags.has(FLAG_DRIVER) - ? 'dance.locals.triangulated_read' - : 'dance.driver.one_step_practice', + conversationId: 'dance.driver.one_step_practice', prompt: state.flags.has(FLAG_DRIVER) - ? 'dance.locals.triangulated_read.03' + ? 'dance.driver.one_step_practice.done.01' : 'dance.driver.one_step_practice.01' }); plans.push({ spotId: 'operations', conversationId: 'dance.operations_helper.handoff_check', - prompt: 'dance.operations_helper.handoff_check.01' + prompt: state.flags.has(FLAG_OPS) + ? 'dance.operations_helper.handoff_check.done.01' + : 'dance.operations_helper.handoff_check.01' }); plans.push({ spotId: 'driver', @@ -177,7 +194,7 @@ function resolveDanceInteractables( }); } - if (state.beat === 'dance_ready') { + if (state.beat === 'dance_ready' || bothReady(state)) { plans.push({ spotId: 'gate', conversationId: 'dance.setup_clearance', @@ -198,12 +215,7 @@ function resolveDanceInteractables( }); } - return collectNearbyFromPlans( - state.progress, - SPOTS, - plans, - (key) => catalog.lines[key] ?? key - ); + return collectNearbyFromPlans(state.progress, SPOTS, plans, line); } function resolveDanceActors(state: RidgeWorldState): RidgeActorPresence[] { @@ -219,56 +231,56 @@ function resolveDanceActors(state: RidgeWorldState): RidgeActorPresence[] { { id: 'traveler', label: 'Traveler', - progress: 0.18, + progress: 0.14, visible: !cleared, facing: 'right' }, { id: 'driver', - label: 'Hill-Shuttle Driver', - progress: cleared ? 0.86 : 0.35, + label: 'Driver', + progress: cleared ? 0.88 : 0.28, visible: true, facing: cleared ? 'right' : 'left' }, { - id: 'operations-helper', - label: 'Last-Stop Operations Helper', - progress: cleared ? 0.7 : 0.48, + id: 'dance-teacher', + label: 'Teacher', + progress: 0.4, visible: true, - facing: 'left' + facing: 'right' }, { - id: 'dance-teacher', - label: 'Dance Teacher', - progress: 0.42, + id: 'cicka', + label: 'Cicka', + progress: cleared ? 0.72 : 0.48, visible: true, facing: 'right' }, { - id: 'steward', - label: 'Festival Steward', - progress: cleared ? 0.64 : 0.58, + id: 'operations-helper', + label: 'Operations', + progress: cleared ? 0.74 : 0.56, visible: true, facing: 'left' }, { - id: 'cicka', - label: 'Cicka', - progress: cleared ? 0.66 : 0.52, + id: 'steward', + label: 'Steward', + progress: cleared ? 0.7 : 0.64, visible: true, - facing: 'right' + facing: 'left' }, { id: 'counterpart-cat', - label: 'Quiet counterpart cat', - progress: 0.68, + label: 'Quiet cat', + progress: 0.76, visible: cleared, facing: 'left' }, { id: 'shuttle', - label: 'Last daylight shuttle', - progress: 0.88, + label: 'Shuttle', + progress: 0.9, visible: true, facing: 'right' } @@ -306,15 +318,15 @@ function resolveDanceConversation( : [ { id: 'offer-help', - label: 'Maybe one small step would help', + label: 'Find the Dance Teacher for him', lines: [ - line('dance.driver.shuttle_delay.choice.help', 'hillShuttleDriver') - ], - outcome: undefined + line('dance.driver.shuttle_delay.choice.help', 'hillShuttleDriver'), + line('dance.driver.shuttle_delay.choice.help_hint', 'prompt') + ] }, { id: 'wait', - label: 'I will ask around the plaza', + label: 'Ask around the plaza first', lines: [line('dance.driver.shuttle_delay.choice.wait', 'prompt')] } ] @@ -410,7 +422,10 @@ function resolveDanceConversation( line('dance.cicka.resting_spot.02', 'prompt'), line('dance.cicka.resting_spot.03', 'cicka') ] - : [line('dance.cicka.resting_spot.01', 'prompt'), line('dance.cicka.resting_spot.03', 'cicka')] + : [ + line('dance.cicka.resting_spot.01', 'prompt'), + line('dance.cicka.resting_spot.03', 'cicka') + ] }; default: return null; @@ -419,13 +434,16 @@ function resolveDanceConversation( function describeDanceAmbience(state: RidgeWorldState): string { if (state.beat === 'dance_cleared') { - return 'Lanterns settle. The last daylight shuttle waits with a warm sky behind the hill.'; + return 'Lane clear. Board the last daylight shuttle on the right.'; + } + if (state.beat === 'dance_ready' || bothReady(state)) { + return 'Both helpers are ready. Clear the service gate, then take the shuttle.'; } - if (state.beat === 'dance_ready') { - return 'Both shy helpers are ready. One more setup clearance and the road can open.'; + if (state.flags.has(FLAG_OPS) && !state.flags.has(FLAG_DRIVER)) { + return 'Operations handoff done. The Dance Teacher can teach one private step.'; } - if (bothReady(state)) { - return 'The plaza almost breathes. A folded song request could finish the practical courage.'; + if (state.flags.has(FLAG_DRIVER) && !state.flags.has(FLAG_OPS)) { + return 'Driver practiced one step. The Operations Helper still needs a handoff check.'; } - return 'Daytime festival setup. Barriers, lantern crates, and a shuttle that cannot leave yet.'; + return 'Last-Stop Plaza setup. Talk to Operations (lantern) and the Dance Teacher (skirt pose).'; } diff --git a/src/game/core/ridge/content/testRouteCatalog.ts b/src/game/core/ridge/content/testRouteCatalog.ts index 16f1564..be1a75e 100644 --- a/src/game/core/ridge/content/testRouteCatalog.ts +++ b/src/game/core/ridge/content/testRouteCatalog.ts @@ -50,34 +50,35 @@ export const TEST_ROUTE_DIALOGUE_CATALOG: RidgeRouteDialogueCatalog = { lines: { 'dance.traveler.relay_wayfinding.01': 'Relay is up the hill shuttle.', 'dance.traveler.relay_wayfinding.02': 'Last daylight ride, after setup clears.', - 'dance.traveler.relay_wayfinding.03': 'Ask at the shuttle and operations table.', + 'dance.traveler.relay_wayfinding.03': 'Help Operations, then the Dance Teacher.', 'dance.driver.shuttle_delay.01': 'Cannot leave until the steward opens the gate.', 'dance.driver.shuttle_delay.02': 'Clipboard says ready. Feet say otherwise.', 'dance.driver.shuttle_delay.03': 'He keeps rereading the same line.', - 'dance.driver.shuttle_delay.choice.help': 'One step. Privately. Maybe.', + 'dance.driver.shuttle_delay.choice.help': 'One step. Privately. Maybe the Dance Teacher…', + 'dance.driver.shuttle_delay.choice.help_hint': 'Find the Dance Teacher (skirt pose).', 'dance.driver.shuttle_delay.choice.wait': 'You give him space and ask around.', - 'dance.operations_helper.handoff_check.01': 'If the lantern line fails, I fail.', - 'dance.operations_helper.handoff_check.02': 'Check the details with me once.', - 'dance.operations_helper.handoff_check.03': 'Help with the operations handoff', - 'dance.operations_helper.handoff_check.done.01': 'The teacher can keep watch.', - 'dance.operations_helper.handoff_check.done.02': 'She almost believes the plaza can breathe.', - 'dance.locals.triangulated_read.01': 'Road opens after setup check.', + 'dance.operations_helper.handoff_check.01': 'Help check the operations handoff', + 'dance.operations_helper.handoff_check.02': 'If the lantern line fails, I fail.', + 'dance.operations_helper.handoff_check.03': 'You check the crates with her.', + 'dance.operations_helper.handoff_check.done.01': 'Handoff done — find the Dance Teacher next', + 'dance.operations_helper.handoff_check.done.02': 'She can almost leave the plaza alone.', + 'dance.locals.triangulated_read.01': 'Road opens after setup clears at the gate.', 'dance.locals.triangulated_read.02': 'She waits on one perfect lantern. He waits on anything except asking.', - 'dance.locals.triangulated_read.03': 'They practice the art of being unavailable.', - 'dance.driver.one_step_practice.01': 'One private step. No audience.', + 'dance.locals.triangulated_read.03': 'Help Operations, then ask me for one private step.', + 'dance.driver.one_step_practice.01': 'Practice one private dance step', 'dance.driver.one_step_practice.02': 'He learns exactly one shared rhythm.', 'dance.driver.one_step_practice.03': 'Okay. I can offer that much later.', - 'dance.driver.one_step_practice.done.01': 'He has his one step.', - 'dance.driver.one_step_practice.done.02': 'Dignity intact.', - 'dance.driver.folded_song_request.01': 'Help me fold a tiny song request.', + 'dance.driver.one_step_practice.done.01': 'Step learned — clear the service gate next', + 'dance.driver.one_step_practice.done.02': 'Dignity intact. Clear the gate next.', + 'dance.driver.folded_song_request.01': 'Help fold a tiny song request', 'dance.driver.folded_song_request.02': 'No confession. Just one dance later.', 'dance.driver.folded_song_request.03': 'She reads it. Soft nod. No spotlight.', - 'dance.driver.folded_song_request.04': 'The practical courage lands.', - 'dance.setup_clearance.01': 'Help with remaining preparations', + 'dance.driver.folded_song_request.04': 'Now clear the service gate on the right.', + 'dance.setup_clearance.01': 'Clear the service gate for the shuttle', 'dance.setup_clearance.02': 'Secure the lantern line.', 'dance.setup_clearance.03': 'Tape the service-lane clear.', 'dance.setup_clearance.04': 'Gate open. Last daylight window.', - 'dance.setup_clearance.05': 'Flip the shuttle sign.', + 'dance.setup_clearance.05': 'Shuttle sign flips. Board the van to the right.', 'dance.shuttle.last_daylight_ride.01': 'All aboard the last ride.', 'dance.shuttle.last_daylight_ride.02': 'The hill lifts into sunset paper.', 'dance.shuttle.last_daylight_ride.03': 'Relay waits quiet above.', diff --git a/src/game/core/ridge/observe.ts b/src/game/core/ridge/observe.ts index 7f6df57..e3e5c61 100644 --- a/src/game/core/ridge/observe.ts +++ b/src/game/core/ridge/observe.ts @@ -86,8 +86,12 @@ function buildHints( hints.unshift(`interact — ${nearby[0].prompt} (${nearby[0].label})`); } if (blockedAhead) { + const blockedMessage = stage.blockedMessage; hints.unshift( - stage.blockedMessage ?? 'The path east is blocked until the local problem clears.' + (typeof blockedMessage === 'function' + ? blockedMessage(state) + : blockedMessage) ?? + 'The path east is blocked until the local problem clears.' ); } if (state.beat === 'relay_complete') { diff --git a/src/game/core/ridge/session.test.ts b/src/game/core/ridge/session.test.ts index 56425ad..3f84563 100644 --- a/src/game/core/ridge/session.test.ts +++ b/src/game/core/ridge/session.test.ts @@ -147,29 +147,27 @@ function playConcert(session: RidgeConsoleSession): void { } function playDance(session: RidgeConsoleSession): void { - walkTo(session, 0.18); + walkTo(session, 0.14); expect(session.exec('interact traveler').ok).toBe(true); drainConversation(session); - walkTo(session, 0.48); + walkTo(session, 0.56); expect(session.exec('interact operations').ok).toBe(true); drainConversation(session); - walkTo(session, 0.42); + walkTo(session, 0.4); expect(session.exec('interact teacher').ok).toBe(true); drainConversation(session); expect(session.observe().beat).toBe('dance_ready'); - walkTo(session, 0.35); - expect(session.exec('interact driver').ok).toBe(true); - drainConversation(session); - - walkTo(session, 0.62); - expect(session.exec('interact gate').ok).toBe(true); + // Nearest interact at the soft wall should prefer the gate over the steward. + walkTo(session, 0.68); + expect(session.exec('interact').ok).toBe(true); + expect(session.observe().conversation?.id).toBe('dance.setup_clearance'); drainConversation(session); expect(session.observe().beat).toBe('dance_cleared'); - walkTo(session, 0.88); + walkTo(session, 0.9); expect(session.exec('interact shuttle').ok).toBe(true); drainConversation(session); } diff --git a/src/game/core/ridge/session.ts b/src/game/core/ridge/session.ts index 092aca9..9d72a6e 100644 --- a/src/game/core/ridge/session.ts +++ b/src/game/core/ridge/session.ts @@ -141,8 +141,11 @@ export class RidgeConsoleSession { ) { nextProgress = blockedAt; blocked = true; + const blockedMessage = this.stage.blockedMessage; message = - this.stage.blockedMessage ?? + (typeof blockedMessage === 'function' + ? blockedMessage(this.state) + : blockedMessage) ?? 'Something blocks the way east. Talk to someone nearby.'; } @@ -166,7 +169,9 @@ export class RidgeConsoleSession { return this.fail('Already in conversation. Use advance / choose / leave.'); } - const nearby = this.stage.resolveInteractables(this.state); + const nearby = [...this.stage.resolveInteractables(this.state)].sort( + (a, b) => a.distance - b.distance + ); if (nearby.length === 0) { return this.fail('Nothing in reach.'); } diff --git a/src/game/core/ridge/types.ts b/src/game/core/ridge/types.ts index e7cedcf..9d53bcf 100644 --- a/src/game/core/ridge/types.ts +++ b/src/game/core/ridge/types.ts @@ -133,7 +133,7 @@ export interface RidgeStageDefinition { /** Soft wall until the crossing opens. */ blockedProgress?: number; /** Message when the soft wall stops the player. */ - blockedMessage?: string; + blockedMessage?: string | ((state: RidgeWorldState) => string); /** When true, blockedProgress no longer applies. */ isCrossingOpen?: (state: RidgeWorldState) => boolean; resolveInteractables: (state: RidgeWorldState) => readonly RidgeInteractable[]; diff --git a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts index f3bf6af..85c71e9 100644 --- a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts +++ b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts @@ -11,12 +11,12 @@ import { drawStickDriver, drawStickGuitar, drawStickGuitarist, - drawStickNpc, drawStickOperationsHelper, drawStickPlayer, drawStickShuttle, drawStickSteward, - drawStickToyCar + drawStickToyCar, + drawStickTraveler } from './stickFigures'; const PAPER = 0xfbfbf9; @@ -45,6 +45,7 @@ export class StickVisualProvider implements RidgeVisualProvider { private readonly actors: Phaser.GameObjects.Graphics; private readonly overlay: Phaser.GameObjects.Graphics; private promptText?: Phaser.GameObjects.Text; + private readonly nameLabels = new Map(); private destroyed = false; constructor(scene: Phaser.Scene, options: StickVisualProviderOptions = {}) { @@ -84,8 +85,10 @@ export class StickVisualProvider implements RidgeVisualProvider { this.drawBackground(view.areaId, view.crossingOpen, view.beat); this.actors.clear(); + const visibleIds = new Set(); for (const actor of view.actors) { if (!actor.visible) continue; + visibleIds.add(actor.id); const x = this.worldXForProgress(actor.progress); const y = GROUND_Y; switch (actor.id) { @@ -112,7 +115,7 @@ export class StickVisualProvider implements RidgeVisualProvider { drawStickGuitar(this.actors, x + 16, y + 2, 1.1); break; case 'traveler': - drawStickNpc(this.actors, x, y, actor.facing, 1); + drawStickTraveler(this.actors, x, y, actor.facing, 1); break; case 'driver': drawStickDriver(this.actors, x, y, actor.facing, 1.05); @@ -130,6 +133,14 @@ export class StickVisualProvider implements RidgeVisualProvider { drawStickShuttle(this.actors, x, y, 1.1); break; } + + if (actor.id !== 'player' && actor.id !== 'toy-car' && actor.id !== 'guitar') { + this.syncNameLabel(actor.id, actor.label, x, y); + } + } + + for (const [id, label] of this.nameLabels) { + if (!visibleIds.has(id)) label.setVisible(false); } const player = view.actors.find((actor) => actor.id === 'player'); @@ -138,7 +149,7 @@ export class StickVisualProvider implements RidgeVisualProvider { const x = this.worldXForProgress(player.progress); this.promptText .setText(`[E] ${view.nearbyPrompt}`) - .setPosition(x, GROUND_Y - 110) + .setPosition(x, GROUND_Y - 130) .setVisible(true); } else { this.promptText.setVisible(false); @@ -155,6 +166,26 @@ export class StickVisualProvider implements RidgeVisualProvider { this.root.destroy(true); this.promptText?.destroy(); this.promptText = undefined; + for (const label of this.nameLabels.values()) label.destroy(); + this.nameLabels.clear(); + } + + private syncNameLabel(id: string, text: string, x: number, y: number): void { + let label = this.nameLabels.get(id); + if (!label) { + label = this.scene.add + .text(0, 0, text, { + fontFamily: 'Caveat, Comic Neue, cursive', + fontSize: '16px', + color: '#1a1a1a', + backgroundColor: '#fbfbf9aa', + padding: { x: 4, y: 1 } + }) + .setOrigin(0.5, 1) + .setDepth(35); + this.nameLabels.set(id, label); + } + label.setText(text).setPosition(x, y - 78).setVisible(true); } private drawBackground( @@ -277,17 +308,20 @@ export class StickVisualProvider implements RidgeVisualProvider { g.lineBetween(x, GROUND_Y, x, GROUND_Y - 90); g.strokeRect(x - 8, GROUND_Y - 110, 16, 20); } - // service gate - const gate = this.worldXForProgress(0.62); + // service gate (matches soft-wall progress) + const gate = this.worldXForProgress(0.68); g.lineStyle(3, INK, crossingOpen ? 0.25 : 1); - g.strokeRect(gate - 30, GROUND_Y - 80, 60, 80); + g.strokeRect(gate - 36, GROUND_Y - 90, 72, 90); + g.lineStyle(2, INK, crossingOpen ? 0.2 : 0.7); + g.lineBetween(gate - 20, GROUND_Y - 70, gate + 20, GROUND_Y - 70); + g.lineBetween(gate - 20, GROUND_Y - 50, gate + 20, GROUND_Y - 50); if (crossingOpen) { g.lineStyle(2, INK, 0.4); - g.lineBetween(gate + 30, GROUND_Y - 80, gate + 70, GROUND_Y - 40); + g.lineBetween(gate + 36, GROUND_Y - 90, gate + 80, GROUND_Y - 40); } - // dance floor edge + // dance floor edge near teacher g.lineStyle(2, INK, 0.35); - g.strokeEllipse(this.worldXForProgress(0.45), GROUND_Y - 20, 120, 30); + g.strokeEllipse(this.worldXForProgress(0.4), GROUND_Y - 20, 100, 28); } private drawRelaySet( diff --git a/src/game/scenes/ridge/art/stick/stickFigures.ts b/src/game/scenes/ridge/art/stick/stickFigures.ts index 672df42..6fd0935 100644 --- a/src/game/scenes/ridge/art/stick/stickFigures.ts +++ b/src/game/scenes/ridge/art/stick/stickFigures.ts @@ -1,3 +1,5 @@ +// Distinct NPC silhouettes intentionally branch on role props. +// fallow-ignore-file complexity import type * as Phaser from 'phaser'; import type { RidgeFacing } from '@/game/core/ridge'; @@ -16,23 +18,17 @@ export function drawStickPlayer( g.lineStyle(3, INK, 1); g.fillStyle(PAPER, 1); - // head g.fillCircle(x, y - s * 1.7, s * 0.45); g.strokeCircle(x, y - s * 1.7, s * 0.45); - - // body g.lineBetween(x, y - s * 1.25, x, y - s * 0.2); - - // arms g.lineBetween(x, y - s * 0.95, x + dir * s * 0.7, y - s * 0.55); g.lineBetween(x, y - s * 0.95, x - dir * s * 0.55, y - s * 0.5); - - // legs g.lineBetween(x, y - s * 0.2, x - s * 0.4, y + s * 0.55); g.lineBetween(x, y - s * 0.2, x + s * 0.4, y + s * 0.55); - - // tiny backpack mark - g.strokeRect(x - dir * s * 0.55, y - s * 1.05, s * 0.28, s * 0.45); + // backpack — player signature + g.fillStyle(INK, 0.12); + g.fillRect(x - dir * s * 0.55, y - s * 1.05, s * 0.32, s * 0.5); + g.strokeRect(x - dir * s * 0.55, y - s * 1.05, s * 0.32, s * 0.5); } export function drawStickCicka( @@ -44,22 +40,14 @@ export function drawStickCicka( const s = 12 * scale; g.lineStyle(2.5, INK, 1); g.fillStyle(PAPER, 1); - - // body loaf g.fillEllipse(x, y - s * 0.35, s * 1.5, s * 0.9); g.strokeEllipse(x, y - s * 0.35, s * 1.5, s * 0.9); - - // head g.fillCircle(x + s * 0.7, y - s * 0.75, s * 0.45); g.strokeCircle(x + s * 0.7, y - s * 0.75, s * 0.45); - - // ears g.lineBetween(x + s * 0.45, y - s * 1.05, x + s * 0.35, y - s * 1.45); g.lineBetween(x + s * 0.35, y - s * 1.45, x + s * 0.6, y - s * 1.1); g.lineBetween(x + s * 0.85, y - s * 1.05, x + s * 0.95, y - s * 1.45); g.lineBetween(x + s * 0.95, y - s * 1.45, x + s * 0.7, y - s * 1.1); - - // tail g.beginPath(); g.moveTo(x - s * 0.75, y - s * 0.35); g.lineTo(x - s * 1.2, y - s * 0.9); @@ -73,17 +61,13 @@ export function drawStickDraftsperson( facing: RidgeFacing, scale = 1 ): void { - drawStickPerson(g, x, y, facing, scale * 1.05, 'clipboard'); const s = 17 * scale; const dir = facing === 'left' ? -1 : 1; - // blueprint board (wider than clipboard prop) - g.strokeRect(x + dir * s * 0.55, y - s * 0.95, s * 0.7, s * 0.5); - g.lineBetween( - x + dir * s * 0.65, - y - s * 0.7, - x + dir * s * 1.1, - y - s * 0.7 - ); + drawBasePerson(g, x, y, facing, scale, { hair: 'messy' }); + g.lineStyle(2.5, INK, 1); + g.strokeRect(x + dir * s * 0.5, y - s * 1.05, s * 0.85, s * 0.6); + g.lineBetween(x + dir * s * 0.6, y - s * 0.75, x + dir * s * 1.15, y - s * 0.75); + g.lineBetween(x + dir * s * 0.6, y - s * 0.6, x + dir * s * 1.0, y - s * 0.6); } export function drawStickToyCar( @@ -101,34 +85,76 @@ export function drawStickToyCar( g.strokeCircle(x + s * 0.55, y + s * 0.25, s * 0.28); } -function drawStickPerson( +interface PersonStyle { + hair?: 'messy' | 'bun' | 'cap' | 'hat' | 'ponytail'; + skirt?: boolean; + apron?: boolean; + raisedArm?: boolean; + walkingStick?: boolean; +} + +function drawBasePerson( g: Phaser.GameObjects.Graphics, x: number, y: number, facing: RidgeFacing, scale: number, - prop?: 'guitar' | 'clipboard' | 'lantern' + style: PersonStyle = {} ): void { const s = 16 * scale; const dir = facing === 'left' ? -1 : 1; g.lineStyle(3, INK, 1); g.fillStyle(PAPER, 1); + g.fillCircle(x, y - s * 1.65, s * 0.4); g.strokeCircle(x, y - s * 1.65, s * 0.4); + + if (style.hair === 'messy') { + g.lineBetween(x - s * 0.25, y - s * 1.95, x - s * 0.35, y - s * 2.2); + g.lineBetween(x, y - s * 2.0, x + s * 0.1, y - s * 2.25); + g.lineBetween(x + s * 0.25, y - s * 1.95, x + s * 0.4, y - s * 2.15); + } else if (style.hair === 'bun') { + g.fillCircle(x, y - s * 2.05, s * 0.22); + g.strokeCircle(x, y - s * 2.05, s * 0.22); + } else if (style.hair === 'cap') { + g.lineBetween(x - s * 0.45, y - s * 1.75, x + s * 0.45, y - s * 1.75); + g.strokeRect(x - s * 0.35, y - s * 2.05, s * 0.7, s * 0.3); + } else if (style.hair === 'hat') { + g.strokeRect(x - s * 0.28, y - s * 2.15, s * 0.56, s * 0.35); + g.lineBetween(x - s * 0.5, y - s * 1.8, x + s * 0.5, y - s * 1.8); + } else if (style.hair === 'ponytail') { + g.beginPath(); + g.moveTo(x - dir * s * 0.25, y - s * 1.7); + g.lineTo(x - dir * s * 0.7, y - s * 1.35); + g.strokePath(); + } + g.lineBetween(x, y - s * 1.25, x, y - s * 0.15); - g.lineBetween(x, y - s * 0.95, x + dir * s * 0.7, y - s * 0.55); - g.lineBetween(x, y - s * 0.95, x - dir * s * 0.5, y - s * 0.5); - g.lineBetween(x, y - s * 0.15, x - s * 0.35, y + s * 0.55); - g.lineBetween(x, y - s * 0.15, x + s * 0.35, y + s * 0.55); - - if (prop === 'guitar') { - g.strokeEllipse(x + dir * s * 0.55, y - s * 0.55, s * 0.55, s * 0.85); - g.lineBetween(x + dir * s * 0.55, y - s * 1.0, x + dir * s * 0.55, y - s * 1.35); - } else if (prop === 'clipboard') { - g.strokeRect(x + dir * s * 0.45, y - s * 0.95, s * 0.45, s * 0.6); - } else if (prop === 'lantern') { - g.strokeRect(x + dir * s * 0.55, y - s * 0.85, s * 0.35, s * 0.45); - g.lineBetween(x + dir * s * 0.72, y - s * 0.85, x + dir * s * 0.72, y - s * 1.1); + + if (style.raisedArm) { + g.lineBetween(x, y - s * 0.95, x + dir * s * 0.55, y - s * 1.45); + g.lineBetween(x, y - s * 0.95, x - dir * s * 0.55, y - s * 0.5); + } else { + g.lineBetween(x, y - s * 0.95, x + dir * s * 0.7, y - s * 0.55); + g.lineBetween(x, y - s * 0.95, x - dir * s * 0.5, y - s * 0.5); + } + + if (style.skirt) { + g.lineBetween(x, y - s * 0.15, x - s * 0.55, y + s * 0.55); + g.lineBetween(x, y - s * 0.15, x + s * 0.55, y + s * 0.55); + g.lineBetween(x - s * 0.55, y + s * 0.55, x + s * 0.55, y + s * 0.55); + } else { + g.lineBetween(x, y - s * 0.15, x - s * 0.35, y + s * 0.55); + g.lineBetween(x, y - s * 0.15, x + s * 0.35, y + s * 0.55); + } + + if (style.apron) { + g.strokeRect(x - s * 0.28, y - s * 0.95, s * 0.56, s * 0.7); + } + + if (style.walkingStick) { + g.lineStyle(2.5, INK, 1); + g.lineBetween(x + dir * s * 0.7, y - s * 0.55, x + dir * s * 0.85, y + s * 0.55); } } @@ -139,10 +165,15 @@ export function drawStickGuitarist( facing: RidgeFacing, scale = 1 ): void { - drawStickPerson(g, x, y, facing, scale, 'guitar'); - // tiny wrist wrap - g.lineStyle(2, INK, 0.8); - g.lineBetween(x + 8, y - 18 * scale, x + 14, y - 14 * scale); + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + drawBasePerson(g, x, y, facing, scale, { hair: 'messy' }); + g.lineStyle(2.5, INK, 1); + g.strokeEllipse(x + dir * s * 0.55, y - s * 0.5, s * 0.6, s * 0.95); + g.lineBetween(x + dir * s * 0.55, y - s * 1.0, x + dir * s * 0.55, y - s * 1.4); + // wrist wrap + g.lineStyle(3, INK, 0.7); + g.lineBetween(x + dir * s * 0.35, y - s * 0.7, x + dir * s * 0.55, y - s * 0.55); } export function drawStickCrowd( @@ -151,9 +182,9 @@ export function drawStickCrowd( y: number, scale = 1 ): void { - drawStickPerson(g, x - 14 * scale, y, 'left', scale * 0.85); - drawStickPerson(g, x, y, 'right', scale * 0.9); - drawStickPerson(g, x + 16 * scale, y, 'left', scale * 0.8); + drawBasePerson(g, x - 18 * scale, y, 'left', scale * 0.8, { hair: 'cap' }); + drawBasePerson(g, x, y, 'right', scale * 0.9, { hair: 'messy' }); + drawBasePerson(g, x + 20 * scale, y, 'left', scale * 0.75, { hair: 'ponytail' }); } export function drawStickGuitar( @@ -169,14 +200,19 @@ export function drawStickGuitar( g.strokeRect(x - s * 0.15, y - s * 1.65, s * 0.3, s * 0.25); } -export function drawStickNpc( +export function drawStickTraveler( g: Phaser.GameObjects.Graphics, x: number, y: number, facing: RidgeFacing, scale = 1 ): void { - drawStickPerson(g, x, y, facing, scale); + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + drawBasePerson(g, x, y, facing, scale, { hair: 'ponytail', walkingStick: true }); + // travel pack + g.lineStyle(2.5, INK, 1); + g.strokeRect(x - dir * s * 0.55, y - s * 1.1, s * 0.38, s * 0.55); } export function drawStickDriver( @@ -186,7 +222,16 @@ export function drawStickDriver( facing: RidgeFacing, scale = 1 ): void { - drawStickPerson(g, x, y, facing, scale, 'clipboard'); + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + drawBasePerson(g, x, y, facing, scale, { hair: 'cap' }); + g.lineStyle(2.5, INK, 1); + // big clipboard + g.fillStyle(PAPER, 1); + g.fillRect(x + dir * s * 0.4, y - s * 1.1, s * 0.55, s * 0.75); + g.strokeRect(x + dir * s * 0.4, y - s * 1.1, s * 0.55, s * 0.75); + g.lineBetween(x + dir * s * 0.5, y - s * 0.85, x + dir * s * 0.85, y - s * 0.85); + g.lineBetween(x + dir * s * 0.5, y - s * 0.65, x + dir * s * 0.8, y - s * 0.65); } export function drawStickOperationsHelper( @@ -196,7 +241,17 @@ export function drawStickOperationsHelper( facing: RidgeFacing, scale = 1 ): void { - drawStickPerson(g, x, y, facing, scale, 'lantern'); + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + drawBasePerson(g, x, y, facing, scale, { hair: 'ponytail', apron: true }); + // lantern held high + g.lineStyle(2.5, INK, 1); + g.lineBetween(x + dir * s * 0.55, y - s * 0.55, x + dir * s * 0.7, y - s * 1.15); + g.strokeRect(x + dir * s * 0.55, y - s * 1.45, s * 0.4, s * 0.4); + g.lineBetween(x + dir * s * 0.75, y - s * 1.45, x + dir * s * 0.75, y - s * 1.65); + // warm hatch inside lantern + g.lineStyle(1.5, INK, 0.45); + g.lineBetween(x + dir * s * 0.62, y - s * 1.35, x + dir * s * 0.88, y - s * 1.15); } export function drawStickDanceTeacher( @@ -206,9 +261,11 @@ export function drawStickDanceTeacher( facing: RidgeFacing, scale = 1 ): void { - drawStickPerson(g, x, y, facing, scale * 1.05); - g.lineStyle(2, INK, 0.5); - g.strokeCircle(x, y - 28 * scale, 22 * scale); + drawBasePerson(g, x, y, facing, scale * 1.08, { + hair: 'bun', + skirt: true, + raisedArm: true + }); } export function drawStickSteward( @@ -218,8 +275,13 @@ export function drawStickSteward( facing: RidgeFacing, scale = 1 ): void { - drawStickPerson(g, x, y, facing, scale); - g.strokeRect(x - 6 * scale, y - 38 * scale, 12 * scale, 8 * scale); + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + drawBasePerson(g, x, y, facing, scale * 1.05, { hair: 'hat' }); + // key on belt + g.lineStyle(2, INK, 1); + g.strokeCircle(x + dir * s * 0.35, y - s * 0.2, s * 0.12); + g.lineBetween(x + dir * s * 0.35, y - s * 0.08, x + dir * s * 0.35, y + s * 0.15); } export function drawStickShuttle( @@ -236,4 +298,7 @@ export function drawStickShuttle( g.strokeCircle(x - s, y + s * 0.1, s * 0.28); g.strokeCircle(x + s, y + s * 0.1, s * 0.28); g.strokeRect(x - s * 1.3, y - s * 0.75, s * 0.7, s * 0.4); + // "LAST" mark on side + g.lineStyle(2, INK, 0.55); + g.lineBetween(x - s * 0.2, y - s * 0.55, x + s * 0.9, y - s * 0.55); } diff --git a/src/shared/i18n/messages/en/scenes.ts b/src/shared/i18n/messages/en/scenes.ts index e99f628..ab6c065 100644 --- a/src/shared/i18n/messages/en/scenes.ts +++ b/src/shared/i18n/messages/en/scenes.ts @@ -120,49 +120,57 @@ export const sceneMessages = { "dance.traveler.relay_wayfinding.02": "Last daylight ride—only after setup clears.", "dance.traveler.relay_wayfinding.03": - "Ask at the shuttle, then the operations table.", + "Help Operations with lanterns, then the Dance Teacher with one step.", "dance.driver.shuttle_delay.01": "Can't leave until the steward opens the gate.", "dance.driver.shuttle_delay.02": "Clipboard says ready. My feet disagree.", "dance.driver.shuttle_delay.03": "He keeps rereading the same safe line.", - "dance.driver.shuttle_delay.choice.help": "One step. Privately. Maybe.", + "dance.driver.shuttle_delay.choice.help": + "One step. Privately. Maybe the Dance Teacher…", + "dance.driver.shuttle_delay.choice.help_hint": + "Walk left-of-center to the Dance Teacher (skirt pose, raised arm).", "dance.driver.shuttle_delay.choice.wait": "You give him space and ask around.", "dance.operations_helper.handoff_check.01": - "If the lantern line fails, I fail with it.", + "Help check the operations handoff", "dance.operations_helper.handoff_check.02": - "Check the details with me once.", + "If the lantern line fails, I fail with it.", "dance.operations_helper.handoff_check.03": - "Help with the operations handoff", + "You check the crates with her. One clean pass is enough.", "dance.operations_helper.handoff_check.done.01": - "The teacher can keep watch now.", + "Handoff done — find the Dance Teacher next", "dance.operations_helper.handoff_check.done.02": - "She almost believes the plaza can breathe.", - "dance.locals.triangulated_read.01": "Road opens after the setup check.", + "She can almost leave the plaza alone now.", + "dance.locals.triangulated_read.01": + "Road opens after setup clears at the gate.", "dance.locals.triangulated_read.02": "She waits on one perfect lantern. He waits on anything but asking.", "dance.locals.triangulated_read.03": - "They practice the art of being unavailable.", - "dance.driver.one_step_practice.01": "One private step. No audience.", + "Help Operations, then ask me for one private step for him.", + "dance.driver.one_step_practice.01": "Practice one private dance step", "dance.driver.one_step_practice.02": - "He learns exactly one shared rhythm.", + "No audience. He learns exactly one shared rhythm.", "dance.driver.one_step_practice.03": "Okay. I can offer that much later.", - "dance.driver.one_step_practice.done.01": "He has his one step.", - "dance.driver.one_step_practice.done.02": "Dignity intact.", + "dance.driver.one_step_practice.done.01": + "Step learned — clear the service gate next", + "dance.driver.one_step_practice.done.02": + "Dignity intact. The gate can open when setup finishes.", "dance.driver.folded_song_request.01": - "Help me fold a tiny song request.", + "Help fold a tiny song request", "dance.driver.folded_song_request.02": "No confession. Just one dance later.", "dance.driver.folded_song_request.03": "She reads it. Soft nod. No spotlight.", - "dance.driver.folded_song_request.04": "The practical courage lands.", - "dance.setup_clearance.01": "Help with remaining preparations", + "dance.driver.folded_song_request.04": + "Now clear the service gate on the right.", + "dance.setup_clearance.01": "Clear the service gate for the shuttle", "dance.setup_clearance.02": "Secure the lantern line.", "dance.setup_clearance.03": "Tape the service lane clear.", "dance.setup_clearance.04": "Gate open. Last daylight window.", - "dance.setup_clearance.05": "Flip the shuttle sign.", + "dance.setup_clearance.05": + "Shuttle sign flips. Board the van to the right.", "dance.shuttle.last_daylight_ride.01": "All aboard the last ride.", "dance.shuttle.last_daylight_ride.02": "The hill lifts into sunset paper.", From 5970757346ecc9a7d3ac4f94a558db2cea57f4fe Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Thu, 30 Jul 2026 23:40:02 +0200 Subject: [PATCH 3/4] feat(ridge): add playtest skip/warp for area jumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Console `skip` / `warp` and DEV keys (] and 1–4) jump Compact Ridge Areas so manual playtests can reach Dance/Relay without replaying Bridge. Co-authored-by: Cursor --- docs/game-design/ridge/ridge-snapshot.md | 4 ++ src/game/core/console/commands.ts | 13 +++++- src/game/core/console/session.ts | 11 +++++ src/game/core/console/types.ts | 2 + src/game/core/ridge/commands.ts | 24 +++++++++- src/game/core/ridge/session.test.ts | 17 +++++++ src/game/core/ridge/session.ts | 52 ++++++++++++++++++++- src/game/core/ridge/types.ts | 4 ++ src/game/scenes/ridge/runtime/RidgeScene.ts | 50 ++++++++++++++++++++ 9 files changed, 173 insertions(+), 4 deletions(-) diff --git a/docs/game-design/ridge/ridge-snapshot.md b/docs/game-design/ridge/ridge-snapshot.md index 872afd7..85cef65 100644 --- a/docs/game-design/ridge/ridge-snapshot.md +++ b/docs/game-design/ridge/ridge-snapshot.md @@ -118,8 +118,12 @@ Useful commands: | `advance` | Continue halted conversation | | `choose ` | 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. diff --git a/src/game/core/console/commands.ts b/src/game/core/console/commands.ts index 270a0dd..8a9661e 100644 --- a/src/game/core/console/commands.ts +++ b/src/game/core/console/commands.ts @@ -19,7 +19,9 @@ const SIMPLE_COMMANDS: Record = { 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']); @@ -41,6 +43,10 @@ export function getGameHelpText(): string { 'Ridge conversation:', ' advance | choose ', '', + 'Ridge playtest skips:', + ' skip next area', + ' warp bridge|concert|dance|relay', + '', 'Potassium (discrete campaign):', ' start | fight | draft ', '', @@ -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 }; } diff --git a/src/game/core/console/session.ts b/src/game/core/console/session.ts index 1b87218..53ba76f 100644 --- a/src/game/core/console/session.ts +++ b/src/game/core/console/session.ts @@ -190,6 +190,10 @@ export class GameConsoleSession { case 'advance': case 'choose': return this.handleRidgeTalk(command); + case 'skip': + return this.handleRidgeSkipWarp('skip'); + case 'warp': + return this.handleRidgeSkipWarp(`warp ${command.areaId}`); case 'start': return this.wrapPotassium(startPotassium(this.state)); case 'fight': @@ -205,6 +209,13 @@ export class GameConsoleSession { } } + private handleRidgeSkipWarp(raw: string): GameCommandResult { + if (this.state.mode !== 'ridge') { + return this.fail('skip / warp only work inside Ridge.'); + } + return this.forwardRidge(raw); + } + private handleGo(direction: 'left' | 'right', steps: number): GameCommandResult { if (this.state.mode === 'overlay') { return this.fail('Close the overlay first (close).'); diff --git a/src/game/core/console/types.ts b/src/game/core/console/types.ts index 303d7bf..73c8915 100644 --- a/src/game/core/console/types.ts +++ b/src/game/core/console/types.ts @@ -114,6 +114,8 @@ export type GameCommand = | { type: 'fight' } | { type: 'draft'; choiceIdOrIndex: string } | { type: 'cheat'; action: 'give'; itemId: GameItemId } + | { type: 'skip' } + | { type: 'warp'; areaId: string } | { type: 'unknown'; raw: string }; export type GameSessionEvent = diff --git a/src/game/core/ridge/commands.ts b/src/game/core/ridge/commands.ts index 1fc7240..c8fce93 100644 --- a/src/game/core/ridge/commands.ts +++ b/src/game/core/ridge/commands.ts @@ -13,6 +13,10 @@ const HELP_TEXT = [ ' choose pick a conversation reply', ' leave exit conversation without finishing', '', + 'Playtest skips:', + ' skip jump to the next area start', + ' warp bridge|concert|dance|relay', + '', 'Script mode: separate commands with `;`', 'Example: look; go right 3; interact; advance; advance' ].join('\n'); @@ -29,13 +33,14 @@ const SIMPLE_COMMANDS: Record = { inv: { type: 'inventory' }, i: { type: 'inventory' }, advance: { type: 'advance' }, - next: { type: 'advance' }, continue: { type: 'advance' }, n: { type: 'advance' }, z: { type: 'advance' }, leave: { type: 'leave' }, back: { type: 'leave' }, - close: { type: 'leave' } + close: { type: 'leave' }, + skip: { type: 'skip' }, + next: { type: 'skip' } }; const GO_ALIASES = new Set(['go', 'walk', 'move']); @@ -43,6 +48,16 @@ const LEFT_ALIASES = new Set(['left', 'west', 'a']); const RIGHT_ALIASES = new Set(['right', 'east', 'd']); const INTERACT_ALIASES = new Set(['interact', 'talk', 'use', 'e', 'x']); const CHOOSE_ALIASES = new Set(['choose', 'pick', 'select']); +const WARP_ALIASES = new Set(['warp', 'tp', 'goto', 'jump']); +const AREA_ALIASES: Record = { + bridge: 'bridge', + concert: 'concert', + dance: 'danceFestival', + dancefestival: 'danceFestival', + festival: 'danceFestival', + relay: 'relay', + ending: 'relay' +}; export function getRidgeHelpText(): string { return HELP_TEXT; @@ -72,6 +87,11 @@ export function parseRidgeCommand(rawInput: string): RidgeCommand { if (!args[0]) return { type: 'unknown', raw }; return { type: 'choose', choiceIdOrIndex: args[0] }; } + if (WARP_ALIASES.has(head)) { + const areaId = args[0] ? AREA_ALIASES[args[0].replace(/[_-]/g, '')] : undefined; + if (!areaId) return { type: 'unknown', raw }; + return { type: 'warp', areaId }; + } return { type: 'unknown', raw }; } diff --git a/src/game/core/ridge/session.test.ts b/src/game/core/ridge/session.test.ts index 3f84563..1d326be 100644 --- a/src/game/core/ridge/session.test.ts +++ b/src/game/core/ridge/session.test.ts @@ -64,6 +64,23 @@ describe('RidgeConsoleSession Bridge playthrough', () => { }); }); +describe('RidgeConsoleSession playtest skips', () => { + it('warps and skips across Compact Ridge Areas with guitar when needed', () => { + const session = createRouteSession(); + expect(session.exec('warp dance').ok).toBe(true); + expect(session.observe().areaId).toBe('danceFestival'); + expect(session.observe().inventory).toContain('guitar'); + + expect(session.exec('skip').ok).toBe(true); + expect(session.observe().areaId).toBe('relay'); + + expect(session.exec('skip').ok).toBe(false); + expect(session.exec('warp bridge').ok).toBe(true); + expect(session.observe().areaId).toBe('bridge'); + expect(session.observe().inventory).toEqual([]); + }); +}); + describe('RidgeConsoleSession first-playable full route', () => { it('plays Bridge → Concert → Dance → Relay farewell → Bridge reset', () => { const session = createRouteSession(); diff --git a/src/game/core/ridge/session.ts b/src/game/core/ridge/session.ts index 9d72a6e..bc344ce 100644 --- a/src/game/core/ridge/session.ts +++ b/src/game/core/ridge/session.ts @@ -20,10 +20,17 @@ import type { RidgeStageRegistry, RidgeWorldState } from './types'; -import { RIDGE_INITIAL_BEAT } from './types'; +import { RIDGE_GUITAR_ITEM, RIDGE_INITIAL_BEAT } from './types'; const DEFAULT_STEP = 0.05; +const NEXT_AREA: Record = { + bridge: 'concert', + concert: 'danceFestival', + danceFestival: 'relay', + relay: null +}; + export interface RidgeSessionOptions { /** Preferred: full multi-area registry for Compact Area Transitions. */ stages?: RidgeStageRegistry; @@ -112,6 +119,10 @@ export class RidgeConsoleSession { return this.handleChoose(command.choiceIdOrIndex); case 'leave': return this.handleLeave(); + case 'skip': + return this.handleSkip(); + case 'warp': + return this.handleWarp(command.areaId); case 'unknown': return this.fail( command.raw @@ -235,6 +246,38 @@ export class RidgeConsoleSession { }; } + private handleSkip(): RidgeCommandResult { + const next = NEXT_AREA[this.state.areaId]; + if (!next) { + return this.fail('Already at Relay. Use: warp bridge|concert|dance|relay'); + } + return this.handleWarp(next); + } + + private handleWarp(areaId: RidgeAreaId): RidgeCommandResult { + if (!this.stages && areaId !== this.stage.areaId) { + return this.fail('Warp needs the full route registry (use pnpm ridge:console).'); + } + + this.applyAreaHandoff(areaId); + this.state = { + ...this.state, + inventory: inventoryForArea(areaId), + flags: new Set(), + lastMessage: `DEV warp → ${this.stage.title}` + }; + + return { + ok: true, + message: this.state.lastMessage ?? `Warped to ${areaId}.`, + observation: this.observe(), + events: [ + { type: 'area_handoff', areaId }, + { type: 'beat_changed', beat: this.state.beat } + ] + }; + } + private commitConversationResult(result: { state: RidgeWorldState; events: RidgeSessionEvent[]; @@ -328,6 +371,13 @@ export class RidgeConsoleSession { } } +function inventoryForArea(areaId: RidgeAreaId): string[] { + if (areaId === 'danceFestival' || areaId === 'relay') { + return [RIDGE_GUITAR_ITEM]; + } + return []; +} + function clamp01(value: number): number { if (value < 0) return 0; if (value > 1) return 1; diff --git a/src/game/core/ridge/types.ts b/src/game/core/ridge/types.ts index 9d53bcf..5666a4a 100644 --- a/src/game/core/ridge/types.ts +++ b/src/game/core/ridge/types.ts @@ -199,6 +199,10 @@ export type RidgeCommand = | { type: 'advance' } | { type: 'choose'; choiceIdOrIndex: string } | { type: 'leave' } + /** DEV / AI: jump to the next Compact Ridge Area. */ + | { type: 'skip' } + /** DEV / AI: warp to a named area start. */ + | { type: 'warp'; areaId: RidgeAreaId } | { type: 'unknown'; raw: string }; export type RidgeSessionEvent = diff --git a/src/game/scenes/ridge/runtime/RidgeScene.ts b/src/game/scenes/ridge/runtime/RidgeScene.ts index ee32fbb..421e8e7 100644 --- a/src/game/scenes/ridge/runtime/RidgeScene.ts +++ b/src/game/scenes/ridge/runtime/RidgeScene.ts @@ -54,6 +54,13 @@ export class RidgeScene extends Phaser.Scene { private getRidgeDevControls?: () => RidgeDevControls | undefined; private lastConversationKey: string | null = null; private escJustHandled = false; + private skipKey?: Phaser.Input.Keyboard.Key; + private warpKeys?: { + bridge: Phaser.Input.Keyboard.Key; + concert: Phaser.Input.Keyboard.Key; + dance: Phaser.Input.Keyboard.Key; + relay: Phaser.Input.Keyboard.Key; + }; constructor() { super(PHASER_SCENE_KEYS.ridge); @@ -83,6 +90,15 @@ export class RidgeScene extends Phaser.Scene { this.visuals = new StickVisualProvider(this); this.keys = bindSideViewKeyboard(this.input.keyboard, { includeEscapeKey: true }); + if (import.meta.env.DEV && this.input.keyboard) { + this.skipKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.CLOSED_BRACKET); + this.warpKeys = { + bridge: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE), + concert: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO), + dance: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE), + relay: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR) + }; + } this.cameras.main.setZoom(1); this.syncPresentation(); @@ -94,6 +110,7 @@ export class RidgeScene extends Phaser.Scene { if (!this.session || !this.visuals || !this.keys || this.isPaused) return; this.handleSceneUiActions(); + this.handleDevSkipKeys(); const observation = this.session.observe(); if (observation.mode === 'conversation') { @@ -140,6 +157,37 @@ export class RidgeScene extends Phaser.Scene { this.isPaused = paused; } + private handleDevSkipKeys(): void { + if (!import.meta.env.DEV || !this.session) return; + + if (this.skipKey && Phaser.Input.Keyboard.JustDown(this.skipKey)) { + this.applyResult(this.session.exec('skip')); + this.syncPresentation(); + return; + } + + if (!this.warpKeys) return; + if (Phaser.Input.Keyboard.JustDown(this.warpKeys.bridge)) { + this.applyResult(this.session.exec('warp bridge')); + this.syncPresentation(); + return; + } + if (Phaser.Input.Keyboard.JustDown(this.warpKeys.concert)) { + this.applyResult(this.session.exec('warp concert')); + this.syncPresentation(); + return; + } + if (Phaser.Input.Keyboard.JustDown(this.warpKeys.dance)) { + this.applyResult(this.session.exec('warp dance')); + this.syncPresentation(); + return; + } + if (Phaser.Input.Keyboard.JustDown(this.warpKeys.relay)) { + this.applyResult(this.session.exec('warp relay')); + this.syncPresentation(); + } + } + private handleSceneUiActions(): void { if (!this.session) return; const action = bridgeActions.consumeSceneUiAction(RIDGE_SCENE_ID); @@ -245,6 +293,8 @@ export class RidgeScene extends Phaser.Scene { this.visuals = undefined; this.session = undefined; this.keys = undefined; + this.skipKey = undefined; + this.warpKeys = undefined; this.lastConversationKey = null; } } From 2c086ac0321e6a774d3b8160807c331a8df9a04f Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Thu, 30 Jul 2026 23:44:19 +0200 Subject: [PATCH 4/4] feat(ridge): return to Overworld after dedication ending Until post-game Open Ridge Return exists, finishing the CRT farewell ejects to the street and resets route progress for the next entry. Co-authored-by: Cursor --- docs/game-design/player-manual.md | 3 ++- docs/game-design/ridge/ridge-snapshot.md | 6 +++++- src/game/core/console/session.test.ts | 23 +++++++++++++++++++++ src/game/core/console/session.ts | 18 ++++++++++++++++ src/game/scenes/ridge/runtime/RidgeScene.ts | 3 +++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/game-design/player-manual.md b/docs/game-design/player-manual.md index f10e1a6..b282dc7 100644 --- a/docs/game-design/player-manual.md +++ b/docs/game-design/player-manual.md @@ -74,7 +74,8 @@ Current shipped behavior for interactive mode. Concept and future-design notes l - 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 clean reset back to Bridge. + 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 diff --git a/docs/game-design/ridge/ridge-snapshot.md b/docs/game-design/ridge/ridge-snapshot.md index 85cef65..5ac4e12 100644 --- a/docs/game-design/ridge/ridge-snapshot.md +++ b/docs/game-design/ridge/ridge-snapshot.md @@ -99,9 +99,13 @@ Relay Ending -> Sit and Play beside Cicka -> Route Memory Montage (3 flashes) + Let the song end -> Cicka Threshold Farewell + Dedication Card - -> First Playable Reset Return to Bridge + -> return to Overworld (CRT story ends; Circuit kept) ``` +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`. diff --git a/src/game/core/console/session.test.ts b/src/game/core/console/session.test.ts index ba72dbf..26f8040 100644 --- a/src/game/core/console/session.test.ts +++ b/src/game/core/console/session.test.ts @@ -108,6 +108,29 @@ 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_ROUTE_DIALOGUE_CATALOG, diff --git a/src/game/core/console/session.ts b/src/game/core/console/session.ts index 53ba76f..fbfa236 100644 --- a/src/game/core/console/session.ts +++ b/src/game/core/console/session.ts @@ -358,6 +358,24 @@ export class GameConsoleSession { const ridge = this.ensureRidge(); const result = ridge.exec(raw); const events = mapRidgeEvents(result.events); + + if (events.some((event) => event.type === 'ridge_route_reset')) { + // Dedication card finished — eject to Overworld until post-game exists. + this.ridge = null; + this.state.mode = 'overworld'; + this.state.sceneId = 'overworld'; + this.state.ridgeBeat = null; + const message = + 'For Cicka. The CRT softens — you are back on the street. Circuit stays with you.'; + this.state.lastMessage = message; + return { + ok: result.ok, + message, + observation: this.observe(), + events: [...events, { type: 'scene_returned', sceneId: 'overworld' }] + }; + } + this.state.ridgeBeat = ridge.observe().beat; this.state.lastMessage = result.message; return { diff --git a/src/game/scenes/ridge/runtime/RidgeScene.ts b/src/game/scenes/ridge/runtime/RidgeScene.ts index 421e8e7..9bb193e 100644 --- a/src/game/scenes/ridge/runtime/RidgeScene.ts +++ b/src/game/scenes/ridge/runtime/RidgeScene.ts @@ -232,7 +232,10 @@ export class RidgeScene extends Phaser.Scene { return; } if (event.type === 'route_reset') { + // v0: dedication ends the CRT story and returns to Overworld. + // Post-game Open Ridge Return can replace this later. bridgeActions.resetRidgeFirstPlayableRoute(); + this.onClose(); } }