Merge pull request #78 from sclzboywow/fix/guide-flow-state-v2

fix: stabilize guide progression and avoid inventory-dependent tutorial deadlocks
This commit is contained in:
桃树夭 authored and GitHub committed 2026-09-26 01:17:17 +08:00
commit 5094bdf82e
6 files changed
+233 -65

No files matched your search

@@ -1,15 +1,17 @@
import { InkButton } from '@app/components/ui';
import { consumeResourceMutation } from '@app/lib/resources/mutations';
import { usePlayerSession } from '@app/lib/resources/player';
import { useStory } from '@app/lib/story/useStory';
import { getGuideLesson } from '@shared/guide/catalog';
import {
advanceGuide,
createGuideState,
currentGuideStep,
restoreGuideState,
type GuideState,
} from '@shared/guide/interpreter';
import type { GuideStep } from '@shared/guide/schema';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
type ShownStep = Extract<GuideStep, { type: 'look' | 'press' }>;
import { useSearchParams } from 'react-router';
@@ -85,61 +87,140 @@ function useAnchorHole(anchor: string | null) {
return measured?.anchor === anchor ? measured.hole : null;
}
const GUIDE_RUNTIME_ID = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
function guideStorageKey(cultivatorId: string, lessonId: string): string {
return `daoyou:guide:v2:${cultivatorId}:${lessonId}`;
}
function readStoredGuideCursor(key: string): number {
if (typeof window === 'undefined') return 0;
try {
const raw = window.sessionStorage.getItem(key);
if (!raw) return 0;
const parsed = JSON.parse(raw) as {
runtimeId?: unknown;
cursor?: unknown;
};
return parsed.runtimeId === GUIDE_RUNTIME_ID &&
Number.isInteger(parsed.cursor) &&
Number(parsed.cursor) >= 0
? Number(parsed.cursor)
: 0;
} catch {
return 0;
}
}
function writeStoredGuideCursor(key: string, cursor: number): void {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(
key,
JSON.stringify({ runtimeId: GUIDE_RUNTIME_ID, cursor }),
);
} catch {
// Current-run guide recovery is optional when storage is unavailable.
}
}
function clearStoredGuideCursor(key: string): void {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.removeItem(key);
} catch {
// Ignore storage denial.
}
}
export function GuideOverlay() {
const [searchParams, setSearchParams] = useSearchParams();
const lessonId = searchParams.get('guide');
const story = useStory(Boolean(lessonId));
const player = usePlayerSession(Boolean(lessonId));
const cultivatorId = player.data?.activeCultivator?.id ?? null;
const lesson = lessonId ? getGuideLesson(lessonId) : null;
const allowed = Boolean(
lesson && !story.loading && story.story?.guideLesson === lessonId,
lesson &&
cultivatorId &&
!story.loading &&
!player.loading &&
story.story?.guideLesson === lessonId,
);
const storageKey =
lessonId && cultivatorId ? guideStorageKey(cultivatorId, lessonId) : null;
useEffect(() => {
if (!lessonId || story.loading || allowed) return;
if (!lessonId || story.loading || player.loading || allowed) return;
if (storageKey) clearStoredGuideCursor(storageKey);
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
if (next.get('guide') !== lessonId) return current;
next.delete('guide');
next.delete('guideStep');
return next;
},
{ replace: true },
);
}, [allowed, lessonId, setSearchParams, story.loading]);
}, [
allowed,
lessonId,
player.loading,
setSearchParams,
storageKey,
story.loading,
]);
if (!allowed || !lessonId) return null;
return <GuideSession key={lessonId} lessonId={lessonId} />;
if (!allowed || !lessonId || !storageKey) return null;
return (
<GuideSession
key={`${cultivatorId}:${lessonId}`}
lessonId={lessonId}
storageKey={storageKey}
/>
);
}
function GuideSession({ lessonId }: { lessonId: string }) {
function GuideSession({
lessonId,
storageKey,
}: {
lessonId: string;
storageKey: string;
}) {
const [, setSearchParams] = useSearchParams();
const lesson = getGuideLesson(lessonId);
const [state, setState] = useState<GuideState>(createGuideState);
const [state, setState] = useState<GuideState>(() =>
lesson
? restoreGuideState(lesson, readStoredGuideCursor(storageKey))
: createGuideState(),
);
const [noting, setNoting] = useState(false);
const [noteError, setNoteError] = useState<string>();
const [remembered, setRemembered] = useState<ShownStep | null>(null);
const notingRef = useRef(false);
const step = lesson ? currentGuideStep(lesson, state) : null;
const anchor = step && step.type !== 'end' ? step.anchor : null;
const hole = useAnchorHole(anchor);
const totalSteps =
lesson?.steps.filter((entry) => entry.type !== 'end').length ?? 0;
const currentStepNumber = Math.min(state.cursor + 1, totalSteps);
const close = () => {
const close = useCallback(() => {
clearStoredGuideCursor(storageKey);
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.delete('guide');
next.delete('guideStep');
return next;
},
{ replace: true },
);
};
}, [setSearchParams, storageKey]);
const closeRef = useRef(close);
const notingRef = useRef(false);
useEffect(() => {
closeRef.current = close;
});
const finishLesson = () => {
const finishLesson = useCallback(() => {
if (notingRef.current) return;
notingRef.current = true;
setNoting(true);
@@ -148,7 +229,10 @@ function GuideSession({ lessonId }: { lessonId: string }) {
method: 'POST',
})
.then((response) => consumeResourceMutation(response))
.then(() => closeRef.current())
.then(() => {
clearStoredGuideCursor(storageKey);
close();
})
.catch((reason: unknown) => {
notingRef.current = false;
setNoting(false);
@@ -156,57 +240,51 @@ function GuideSession({ lessonId }: { lessonId: string }) {
reason instanceof Error ? reason.message : '这课没能记下,再试一次。',
);
});
};
}, [close, lessonId, storageKey]);
const finishRef = useRef(finishLesson);
useEffect(() => {
finishRef.current = finishLesson;
});
if (state.finished) finishLesson();
}, [finishLesson, state.finished]);
const advance = () => {
if (!lesson || notingRef.current) return;
let finished = false;
setState((current) => {
const next = advanceGuide(lesson, current);
finished = next.finished && !current.finished;
return next;
});
if (finished && step && step.type !== 'end') setRemembered(step);
if (finished) finishRef.current();
};
const advance = useCallback(() => {
if (!lesson || notingRef.current || state.finished) return;
const active = currentGuideStep(lesson, state);
if (!active || active.type === 'end') return;
const next = advanceGuide(lesson, state);
writeStoredGuideCursor(storageKey, next.cursor);
if (next.finished) setRemembered(active);
setState(next);
}, [lesson, state, storageKey]);
useEffect(() => {
if (!lesson || step?.type !== 'press') return;
const expectedAnchor = step.anchor;
const onClick = (event: MouseEvent) => {
if (notingRef.current) return;
const node = document.querySelector(
`[data-guide="${step.anchor}"]`,
);
if (!(node instanceof HTMLElement)) return;
if (!(event.target instanceof Node) || !node.contains(event.target)) return;
let finished = false;
setState((current) => {
const next = advanceGuide(lesson, current);
finished = next.finished && !current.finished;
return next;
});
if (finished && step.type === 'press') setRemembered(step);
if (finished) finishRef.current();
const hit = event
.composedPath()
.some(
(entry) =>
entry instanceof HTMLElement &&
entry.dataset.guide === expectedAnchor,
);
if (!hit) return;
advance();
};
document.addEventListener('click', onClick);
return () => document.removeEventListener('click', onClick);
}, [lesson, step]);
document.addEventListener('click', onClick, true);
return () => document.removeEventListener('click', onClick, true);
}, [advance, lesson, step]);
const visible = step && step.type !== 'end' ? step : remembered;
if (!lesson || !visible || (!step && !state.finished)) return null;
const calloutWidth = Math.min(288, window.innerWidth - 32);
const calloutHeight = 150;
const calloutHeight = 168;
const bottomReserve = 120;
const below = hole ? hole.top + hole.height + 12 : 0;
const above = hole ? hole.top - 12 - calloutHeight : 0;
const calloutTop = !hole
? Math.max(96, window.innerHeight / 2 - 72)
? Math.max(96, window.innerHeight / 2 - 84)
: below + calloutHeight < window.innerHeight - bottomReserve
? below
: above > 72
@@ -254,9 +332,12 @@ function GuideSession({ lessonId }: { lessonId: string }) {
height: hole.height,
}}
/>
{step?.type === 'look' || state.finished ? (
<div
className="pointer-events-auto absolute"
{step?.type === 'look' && !state.finished ? (
<button
type="button"
aria-label="继续教学"
onClick={advance}
className="pointer-events-auto absolute cursor-pointer bg-transparent"
style={{
top: hole.top,
left: hole.left,
@@ -267,23 +348,30 @@ function GuideSession({ lessonId }: { lessonId: string }) {
) : null}
</>
) : (
<div className="pointer-events-auto absolute inset-0 bg-[#1c1712]/72" />
<div className="pointer-events-none absolute inset-0 bg-[#1c1712]/55" />
)}
<div
role="dialog"
aria-modal="true"
aria-modal={Boolean(hole)}
aria-label={lesson.title}
className="pointer-events-auto absolute bg-paper px-4 py-4 text-ink shadow-[0_12px_40px_rgba(28,23,18,0.28)]"
style={{ top: calloutTop, left: calloutLeft, width: calloutWidth }}
>
<p className="text-xs tracking-[0.22em] text-teal">教学</p>
<div className="flex items-center justify-between gap-3">
<p className="text-xs tracking-[0.22em] text-teal">教学</p>
{totalSteps > 0 ? (
<p className="text-ink-secondary text-xs font-mono">
{state.finished ? totalSteps : currentStepNumber}/{totalSteps}
</p>
) : null}
</div>
<p className="mt-2 text-base leading-7">
{state.finished
? noteError ?? (noting ? '正在记下。' : visible.text)
: hole
? visible.text
: '这一处还没出现。'}
: '这一处还没出现。可先完成当前页面操作,教学会自动跟上。'}
</p>
<div className="mt-3 flex items-center gap-4">
{state.finished && noteError ? (
@@ -20,7 +20,7 @@
{
"type": "look",
"anchor": "alchemy.fire",
"text": "材料齐了,就点开炉炼丹。这一课先指到这里。"
"text": "这里是开炉处。材料与心念备齐后,从这里炼丹;这一课先认到这里。"
},
{ "type": "end" }
]
@@ -8,19 +8,19 @@
"text": "地火器炉在这里。兵器从这口炉里出来。"
},
{
"type": "press",
"type": "look",
"anchor": "forge.archive",
"text": "图纸在图录里。把炼气期法兵带出来。"
"text": "道装图录在这里。图纸决定这一炉要铸成什么。"
},
{
"type": "press",
"anchor": "forge.blueprint",
"text": "带进器炉。"
"anchor": "forge.furnace",
"text": "走近地火器炉。"
},
{
"type": "look",
"anchor": "forge.fire",
"text": "青石和灵石都齐了。点开炉铸造,兵器才会落进袋子。"
"text": "图纸和任意合用灵材备齐后,从这里开炉。真正铸成一件法兵,这一课才算落到手上。"
},
{ "type": "end" }
]
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { getGuideLesson } from './catalog';
describe('guide catalog production chains', () => {
it('keeps the first weapon lesson independent from inventory contents', () => {
const lesson = getGuideLesson('forge-first-weapon');
expect(lesson).not.toBeNull();
expect(
lesson!.steps
.filter((step) => step.type !== 'end')
.map((step) => [step.type, step.anchor]),
).toEqual([
['look', 'forge.furnace'],
['look', 'forge.archive'],
['press', 'forge.furnace'],
['look', 'forge.fire'],
]);
});
it('keeps the alchemy lesson observational at the final fire control', () => {
const lesson = getGuideLesson('alchemy-first-furnace');
const steps = lesson!.steps.filter((step) => step.type !== 'end');
expect(steps.at(-1)).toMatchObject({
type: 'look',
anchor: 'alchemy.fire',
});
expect(
steps.at(-1)?.type === 'look' ? steps.at(-1)?.text : '',
).toContain('先认到这里');
});
it('keeps every production lesson closed by end', () => {
for (const id of [
'alchemy-first-furnace',
'map-qingxi',
'beast-pouch',
'cave-layout',
'forge-first-weapon',
'sect-door',
]) {
const lesson = getGuideLesson(id);
expect(lesson, id).not.toBeNull();
expect(lesson!.steps.at(-1)?.type, id).toBe('end');
}
});
});
+22 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { advanceGuide, createGuideState, currentGuideStep } from './interpreter';
import {
advanceGuide,
createGuideState,
currentGuideStep,
restoreGuideState,
} from './interpreter';
import { parseGuideLesson } from './schema';
const lesson = parseGuideLesson({
@@ -26,6 +31,22 @@ describe('guide interpreter', () => {
expect(currentGuideStep(lesson, done)).toBeNull();
});
it('restores a valid cursor and recognizes an end cursor', () => {
expect(restoreGuideState(lesson, 1)).toEqual({
cursor: 1,
finished: false,
});
expect(restoreGuideState(lesson, 2)).toEqual({
cursor: 2,
finished: true,
});
expect(restoreGuideState(lesson, -1)).toEqual(createGuideState());
expect(restoreGuideState(lesson, 999)).toEqual({
cursor: 2,
finished: true,
});
});
it('rejects a lesson that does not close', () => {
expect(() =>
parseGuideLesson({
+13
View File
@@ -9,6 +9,19 @@ export function createGuideState(): GuideState {
return { cursor: 0, finished: false };
}
export function restoreGuideState(
lesson: GuideLesson,
cursor: number,
): GuideState {
if (!Number.isInteger(cursor) || cursor < 0) return createGuideState();
const safeCursor = Math.min(cursor, lesson.steps.length - 1);
const step = lesson.steps[safeCursor];
return {
cursor: safeCursor,
finished: !step || step.type === 'end',
};
}
export function currentGuideStep(
lesson: GuideLesson,
state: GuideState,