From a0d1cb86f09a1c9d360e83c6372a4be5bff00be9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 17:23:52 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=8D=89=E7=A8=BF=E4=BF=9D=E5=AD=98=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=A6=96=E6=AC=A1=E5=8A=A0=E8=BD=BD=E9=80=9F=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...PTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md | 18 +++ docs/experience/README.md | 1 + ...D_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md | 11 +- .../spacetime-module/src/big_fish/session.rs | 75 ++++++++++- .../spacetime-module/src/custom_world/mod.rs | 68 +++++++++- server-rs/crates/spacetime-module/src/lib.rs | 106 +++++++++++++-- src/App.tsx | 125 +++++++++++++++++- src/RpgRuntimeApp.tsx | 45 +++++++ .../platform-entry/platformEntryTypes.ts | 3 +- ...gEntryFlowShell.agent.interaction.test.tsx | 24 ++-- .../rpg-entry/useRpgEntryLibraryDetail.ts | 2 +- .../RpgRuntimeStageRouter.tsx | 1 - src/hooks/rpg-session/useRpgRuntimeSession.ts | 2 +- .../rpg-session/useRpgSessionPersistence.ts | 2 +- 14 files changed, 440 insertions(+), 43 deletions(-) create mode 100644 docs/experience/AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md create mode 100644 src/RpgRuntimeApp.tsx diff --git a/docs/experience/AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md b/docs/experience/AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md new file mode 100644 index 00000000..81d75bd2 --- /dev/null +++ b/docs/experience/AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md @@ -0,0 +1,18 @@ +# Agent 空会话草稿可见性修正 2026-04-26 + +用户从创作中心点进 RPG 或大鱼吃小鱼工作台时,后端会立即创建 Agent session,并写入一条助手欢迎消息。但在用户尚未发送任何消息、也没有传入种子文本时,这个 session 只是临时工作区,不应进入“我的创作”草稿列表。 + +本次规则: + +1. 只有存在用户消息、非空 seedText、真实草稿数据或已发布状态时,Agent session 才算作品草稿。 +2. 助手欢迎消息、默认 anchorPack、空 `{}` draftProfile 不算用户创作内容。 +3. 过滤必须落在后端 works 聚合层,前端创作中心只消费结果,不负责隐藏空草稿。 +4. RPG 仍保留已发布 profile 和孤立持久草稿 profile 的展示;未发布且仍有活跃 Agent session 的编译 profile 继续去重。 + +涉及入口: + +- `server-rs/crates/spacetime-module/src/lib.rs` +- `server-rs/crates/spacetime-module/src/custom_world/mod.rs` +- `server-rs/crates/spacetime-module/src/big_fish/session.rs` + +后续如果新增玩法创作 Agent,也必须复用同一判断:创建会话不等于创建草稿,作品列表只展示已经被用户实际开始编辑或已经生成结果的会话。 diff --git a/docs/experience/README.md b/docs/experience/README.md index 0380a42c..95f83d28 100644 --- a/docs/experience/README.md +++ b/docs/experience/README.md @@ -29,3 +29,4 @@ - [RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md](./RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md):记录 RPG 底稿阶段角色主形象与场景背景图并行生成约束。 - [PLATFORM_HOME_BANNER_IMAGE_SIZE_FIX_2026-04-25.md](./PLATFORM_HOME_BANNER_IMAGE_SIZE_FIX_2026-04-25.md):记录首页 banner 背景图不能进入普通布局流的修复经验。 - [RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md](./RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md):记录 RPG 发布后首页 / 分类页公开作品列表刷新链路。 +- [AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md](./AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md):记录 Agent 空会话不应进入作品草稿列表的后端判定规则。 diff --git a/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md b/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md index 04ab6cbd..e2fed867 100644 --- a/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md +++ b/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md @@ -24,6 +24,8 @@ - `PlatformEntryFlowShellImpl` 将拼图 Agent、拼图结果页、拼图详情页、拼图运行态、创作货架等非默认首屏组件改为 `lazy`。 - 平台首页 Tab 保留已访问页面的挂载状态,但首访只挂载当前 Tab,避免隐藏的创作页提前触发创作中心等懒加载模块。 - RPG 运行态画布和 overlay host 只在已经进入 RPG 世界后挂载,平台首页不再同步拉取运行态画布链路。 +- 默认 `App` 不再首屏调用 `useRpgRuntimeSession`。平台首页先挂载轻量 `PlatformEntryFlowShell`,用户选择世界、恢复存档或进入 RPG 运行态深链后,才懒加载完整 `RpgRuntimeApp` 和故事/战斗/NPC 交互 hooks。 +- 平台入口 props 移除未使用的 `gameState`,避免轻量首页为了兼容旧签名初始化完整 RPG `GameState`。 - 平台首页资料服务直连 `rpgProfileClient`,避免经过 `services/rpg-entry/index.ts` 把同域其它 client 一并纳入冷转译链路。 ### 3.2 首屏图片门控 @@ -47,7 +49,8 @@ Vite dev server 只对前端真实运行入口保持热更新敏感: 1. Vite ready 后,默认站点首屏不再一次性转译明显非首屏的拼图/玩法结果/运行态组件。 2. 默认首页冷加载 `.tsx` 请求数量下降,创作、拼图、运行态等阶段在用户进入时再加载对应 chunk。 -3. 慢图片、失败图片或生成资源代理慢时,页面主体仍能先显示并保持可操作。 -4. 修改 `docs/`、`server-rs/`、`scripts/` 或测试文件时,不再触发前端页面 reload。 -5. `RouteImageReadyGate` 工具测试覆盖慢图片仍会放行首屏的行为。 -6. 修改中文文件后运行编码检查,确保没有破坏 UTF-8 文本。 +3. 默认首页不再同步加载 RPG story / combat / NPC interaction 运行态 hooks;进入自定义世界或恢复存档后再加载完整运行态。 +4. 慢图片、失败图片或生成资源代理慢时,页面主体仍能先显示并保持可操作。 +5. 修改 `docs/`、`server-rs/`、`scripts/` 或测试文件时,不再触发前端页面 reload。 +6. `RouteImageReadyGate` 工具测试覆盖慢图片仍会放行首屏的行为。 +7. 修改中文文件后运行编码检查,确保没有破坏 UTF-8 文本。 diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 206fb5b3..cc4f5df0 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -182,8 +182,7 @@ pub(crate) fn create_big_fish_session_tx( owner_user_id: input.owner_user_id.clone(), seed_text: input.seed_text.trim().to_string(), current_turn: 0, - // 中文注释:欢迎语和种子推断只是初始上下文,不代表创作者已经推进了共创流程。 - progress_percent: 0, + progress_percent: 20, stage: BigFishCreationStage::CollectingAnchors, anchor_pack_json: serialize_anchor_pack(&anchor_pack) .map_err(|error| error.to_string())?, @@ -239,7 +238,9 @@ pub(crate) fn list_big_fish_works_tx( .db .big_fish_creation_session() .iter() - .filter(|row| row.owner_user_id == input.owner_user_id) + .filter(|row| { + row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row) + }) .map(|row| build_big_fish_work_summary(ctx, &row)) .collect::, _>>()?; @@ -252,6 +253,24 @@ pub(crate) fn list_big_fish_works_tx( Ok(items) } +fn should_include_big_fish_work(ctx: &ReducerContext, row: &BigFishCreationSession) -> bool { + if big_fish_session_has_direct_work_content(row) { + return true; + } + + ctx.db.big_fish_agent_message().iter().any(|message| { + message.session_id == row.session_id + && matches!(message.role, BigFishAgentMessageRole::User) + }) +} + +fn big_fish_session_has_direct_work_content(row: &BigFishCreationSession) -> bool { + // 助手欢迎语和默认 anchorPack 只是工作台初始状态,不应被当成草稿作品。 + !row.seed_text.trim().is_empty() + || row.draft_json.is_some() + || row.stage == BigFishCreationStage::Published +} + pub(crate) fn delete_big_fish_work_tx( ctx: &ReducerContext, input: BigFishWorkDeleteInput, @@ -688,3 +707,53 @@ pub(crate) fn append_big_fish_system_message( created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), }); } + +#[cfg(test)] +mod tests { + use super::*; + + fn build_test_big_fish_session( + seed_text: &str, + draft_json: Option<&str>, + stage: BigFishCreationStage, + ) -> BigFishCreationSession { + BigFishCreationSession { + session_id: "big-fish-session-1".to_string(), + owner_user_id: "user-1".to_string(), + seed_text: seed_text.to_string(), + current_turn: 0, + progress_percent: 20, + stage, + anchor_pack_json: "{}".to_string(), + draft_json: draft_json.map(str::to_string), + asset_coverage_json: "{}".to_string(), + last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()), + publish_ready: false, + created_at: Timestamp::from_micros_since_unix_epoch(1), + updated_at: Timestamp::from_micros_since_unix_epoch(1), + } + } + + #[test] + fn big_fish_direct_work_content_ignores_empty_created_session() { + let empty_session = + build_test_big_fish_session("", None, BigFishCreationStage::CollectingAnchors); + let seeded_session = build_test_big_fish_session( + "想做深海吞噬成长", + None, + BigFishCreationStage::CollectingAnchors, + ); + let drafted_session = build_test_big_fish_session( + "", + Some(r#"{"title":"深海吞噬"}"#), + BigFishCreationStage::DraftReady, + ); + let published_session = + build_test_big_fish_session("", None, BigFishCreationStage::Published); + + assert!(!big_fish_session_has_direct_work_content(&empty_session)); + assert!(big_fish_session_has_direct_work_content(&seeded_session)); + assert!(big_fish_session_has_direct_work_content(&drafted_session)); + assert!(big_fish_session_has_direct_work_content(&published_session)); + } +} diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs index d9e79293..e894c0c6 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + #[spacetimedb::table( accessor = custom_world_profile, index(accessor = by_custom_world_profile_owner_user_id, btree(columns = [owner_user_id])), @@ -1457,10 +1459,14 @@ fn list_custom_world_work_snapshots( validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?; let mut items = Vec::new(); + let mut active_agent_session_ids = HashSet::new(); for session in ctx.db.custom_world_agent_session().iter().filter(|row| { - row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published + row.owner_user_id == input.owner_user_id + && row.stage != RpgAgentStage::Published + && should_include_custom_world_agent_session_work(ctx, row) }) { + active_agent_session_ids.insert(session.session_id.clone()); let gate = build_custom_world_publish_gate_from_session(&session); let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()); let title = resolve_session_work_title(&session, draft_profile.as_ref()); @@ -1504,6 +1510,7 @@ fn list_custom_world_work_snapshots( .custom_world_profile() .iter() .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()) + .filter(|row| should_include_custom_world_profile_work(row, &active_agent_session_ids)) { items.push(CustomWorldWorkSummarySnapshot { work_id: format!("published:{}", profile.profile_id), @@ -1558,6 +1565,63 @@ fn list_custom_world_work_snapshots( Ok(items) } +fn should_include_custom_world_agent_session_work( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, +) -> bool { + if custom_world_agent_session_has_direct_work_content(session) { + return true; + } + + if ctx.db.custom_world_agent_message().iter().any(|message| { + message.session_id == session.session_id && matches!(message.role, RpgAgentMessageRole::User) + }) { + return true; + } + + ctx.db + .custom_world_draft_card() + .iter() + .any(|card| card.session_id == session.session_id) +} + +fn custom_world_agent_session_has_direct_work_content( + session: &CustomWorldAgentSession, +) -> bool { + // 创建会话时写入的助手欢迎语和空 `{}` draftProfile 不算草稿内容; + // 这里只承认用户显式输入的 seed 或已经生成出的真实草稿阶段。 + !session.seed_text.trim().is_empty() + || matches!( + session.stage, + RpgAgentStage::ObjectRefining + | RpgAgentStage::VisualRefining + | RpgAgentStage::LongTailReview + | RpgAgentStage::ReadyToPublish + | RpgAgentStage::Published + ) + || parse_optional_session_object(session.draft_profile_json.as_deref()) + .as_ref() + .is_some_and(|profile| !profile.is_empty()) +} + +fn should_include_custom_world_profile_work( + row: &CustomWorldProfile, + active_agent_session_ids: &HashSet, +) -> bool { + // 已发布 profile 是正式作品;即使来源会话还存在,也必须保留独立入口。 + if row.publication_status == CustomWorldPublicationStatus::Published { + return true; + } + + // 未发布 profile 若来源于仍可继续聊天的 Agent 会话,只是同一草稿的编译产物, + // works 里保留 agent_session 即可,避免草稿分组显示两份同名作品。 + row.source_agent_session_id + .as_ref() + .map_or(true, |session_id| { + !active_agent_session_ids.contains(session_id) + }) +} + fn delete_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionGetInput, @@ -3708,7 +3772,7 @@ fn parse_json_array_or_empty(raw: &str) -> Vec { .unwrap_or_default() } -fn read_first_payload_text(payload: &JsonMap, array_key: &str, scalar_key: &str) -> Option { +fn read_first_payload_text(payload: &JsonMap, array_key: &str, scalar_key: &str) -> Option { payload.get(array_key).and_then(JsonValue::as_array).and_then(|values| values.first()).and_then(JsonValue::as_str) .or_else(|| payload.get(scalar_key).and_then(JsonValue::as_str)) .map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned) diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 61f87617..2d015208 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -2930,7 +2930,9 @@ fn list_custom_world_work_snapshots( let mut active_agent_session_ids = HashSet::new(); for session in ctx.db.custom_world_agent_session().iter().filter(|row| { - row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published + row.owner_user_id == input.owner_user_id + && row.stage != RpgAgentStage::Published + && should_include_custom_world_agent_session_work(ctx, row) }) { active_agent_session_ids.insert(session.session_id.clone()); let gate = build_custom_world_publish_gate_from_session(&session); @@ -3031,6 +3033,44 @@ fn list_custom_world_work_snapshots( Ok(items) } +fn should_include_custom_world_agent_session_work( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, +) -> bool { + if custom_world_agent_session_has_direct_work_content(session) { + return true; + } + + if ctx.db.custom_world_agent_message().iter().any(|message| { + message.session_id == session.session_id + && matches!(message.role, RpgAgentMessageRole::User) + }) { + return true; + } + + ctx.db + .custom_world_draft_card() + .iter() + .any(|card| card.session_id == session.session_id) +} + +fn custom_world_agent_session_has_direct_work_content(session: &CustomWorldAgentSession) -> bool { + // 创建会话时写入的助手欢迎语和空 `{}` draftProfile 不算草稿内容; + // 这里只承认用户显式输入的 seed 或已经生成出的真实草稿阶段。 + !session.seed_text.trim().is_empty() + || matches!( + session.stage, + RpgAgentStage::ObjectRefining + | RpgAgentStage::VisualRefining + | RpgAgentStage::LongTailReview + | RpgAgentStage::ReadyToPublish + | RpgAgentStage::Published + ) + || parse_optional_session_object(session.draft_profile_json.as_deref()) + .as_ref() + .is_some_and(|profile| !profile.is_empty()) +} + fn should_include_custom_world_profile_work( row: &CustomWorldProfile, active_agent_session_ids: &HashSet, @@ -6248,25 +6288,25 @@ fn build_npc_state_snapshot_from_row(row: &NpcState) -> NpcStateSnapshot { mod tests { use super::*; - #[test] - fn resolve_stable_agent_draft_profile_id_prefers_legacy_result_profile_id() { - let session = CustomWorldAgentSession { + fn build_test_custom_world_agent_session( + seed_text: &str, + stage: RpgAgentStage, + draft_profile_json: Option<&str>, + ) -> CustomWorldAgentSession { + CustomWorldAgentSession { session_id: "session-1".to_string(), owner_user_id: "user-1".to_string(), - seed_text: "seed".to_string(), - current_turn: 1, - progress_percent: 100, - stage: RpgAgentStage::ObjectRefining, + seed_text: seed_text.to_string(), + current_turn: 0, + progress_percent: 0, + stage, focus_card_id: None, anchor_content_json: "{}".to_string(), creator_intent_json: None, creator_intent_readiness_json: "{}".to_string(), anchor_pack_json: None, lock_state_json: None, - draft_profile_json: Some( - r#"{"id":"drifted-profile","legacyResultProfile":{"id":"stable-profile"}}"# - .to_string(), - ), + draft_profile_json: draft_profile_json.map(str::to_string), last_assistant_reply: None, publish_gate_json: None, result_preview_json: None, @@ -6278,7 +6318,16 @@ mod tests { checkpoints_json: "[]".to_string(), created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), - }; + } + } + + #[test] + fn resolve_stable_agent_draft_profile_id_prefers_legacy_result_profile_id() { + let session = build_test_custom_world_agent_session( + "seed", + RpgAgentStage::ObjectRefining, + Some(r#"{"id":"drifted-profile","legacyResultProfile":{"id":"stable-profile"}}"#), + ); assert_eq!( resolve_stable_agent_draft_profile_id(&session), @@ -6286,6 +6335,37 @@ mod tests { ); } + #[test] + fn custom_world_agent_session_direct_work_content_ignores_empty_created_session() { + let empty_session = + build_test_custom_world_agent_session("", RpgAgentStage::CollectingIntent, Some("{}")); + let seeded_session = build_test_custom_world_agent_session( + "想做一个海雾群岛", + RpgAgentStage::CollectingIntent, + Some("{}"), + ); + let drafted_session = + build_test_custom_world_agent_session("", RpgAgentStage::ObjectRefining, Some("{}")); + let profile_session = build_test_custom_world_agent_session( + "", + RpgAgentStage::CollectingIntent, + Some(r#"{"worldHook":"海雾会吞掉记错航线的人。"}"#), + ); + + assert!(!custom_world_agent_session_has_direct_work_content( + &empty_session, + )); + assert!(custom_world_agent_session_has_direct_work_content( + &seeded_session, + )); + assert!(custom_world_agent_session_has_direct_work_content( + &drafted_session, + )); + assert!(custom_world_agent_session_has_direct_work_content( + &profile_session, + )); + } + #[test] fn same_agent_draft_profile_candidate_requires_same_owner_active_draft_and_session() { let matching = CustomWorldProfile { diff --git a/src/App.tsx b/src/App.tsx index 89a89463..83e0285b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,125 @@ -import { RpgRuntimeShell } from './components/rpg-runtime-shell/RpgRuntimeShell'; -import { useRpgRuntimeSession } from './hooks/rpg-session/useRpgRuntimeSession'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; + +import { useAuthUi } from './components/auth/AuthUiContext'; +import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell'; +import type { SelectionStage } from './components/platform-entry/platformEntryTypes'; +import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes'; +import { + APP_RUNTIME_ROUTES, + normalizeAppPath, + pushAppHistoryPath, + resolvePathForSelectionStage, + resolveSelectionStageFromPath, +} from './routing/appPageRoutes'; +import type { RpgRuntimeAppIntent } from './RpgRuntimeApp'; +import type { CustomWorldProfile } from './types'; + +const RpgRuntimeApp = lazy(async () => { + const module = await import('./RpgRuntimeApp'); + return { + default: module.RpgRuntimeApp, + }; +}); + +function isRpgRuntimeRoute(pathname: string) { + const normalizedPath = normalizeAppPath(pathname); + return ( + normalizedPath === APP_RUNTIME_ROUTES['rpg-character-select'] || + normalizedPath === APP_RUNTIME_ROUTES['rpg-adventure'] + ); +} export default function App() { - const gameShellProps = useRpgRuntimeSession(); + const authUi = useAuthUi(); + const runtimeIntentTokenRef = useRef(0); + const [runtimeIntent, setRuntimeIntent] = + useState(null); + const [isRuntimeActive, setIsRuntimeActive] = useState(() => + isRpgRuntimeRoute(window.location.pathname), + ); + const [selectionStage, setRawSelectionStage] = useState(() => + resolveSelectionStageFromPath(window.location.pathname), + ); - return ; + const setSelectionStage = useCallback((stage: SelectionStage) => { + setRawSelectionStage(stage); + pushAppHistoryPath(resolvePathForSelectionStage(stage)); + }, []); + + useEffect(() => { + const syncStageFromHistory = () => { + if (isRpgRuntimeRoute(window.location.pathname)) { + setIsRuntimeActive(true); + return; + } + + setIsRuntimeActive(false); + setRawSelectionStage( + resolveSelectionStageFromPath(window.location.pathname), + ); + }; + + window.addEventListener('popstate', syncStageFromHistory); + return () => window.removeEventListener('popstate', syncStageFromHistory); + }, []); + + const createRuntimeIntent = useCallback( + (intent: Omit) => { + runtimeIntentTokenRef.current += 1; + setRuntimeIntent({ + ...intent, + token: runtimeIntentTokenRef.current, + }); + setIsRuntimeActive(true); + }, + [], + ); + + const handleContinueGame = useCallback( + (snapshot?: HydratedSavedGameSnapshot | null) => { + createRuntimeIntent({ + kind: 'snapshot', + snapshot: snapshot ?? null, + }); + }, + [createRuntimeIntent], + ); + + const handleCustomWorldSelect = useCallback( + (customWorldProfile: CustomWorldProfile) => { + createRuntimeIntent({ + kind: 'custom-world', + profile: customWorldProfile, + }); + }, + [createRuntimeIntent], + ); + const platformThemeClass = + authUi?.platformTheme === 'dark' + ? 'platform-theme--dark' + : 'platform-theme--light'; + + if (isRuntimeActive) { + return ( + + + + ); + } + + return ( +
+ {}} + handleCustomWorldSelect={handleCustomWorldSelect} + /> +
+ ); } diff --git a/src/RpgRuntimeApp.tsx b/src/RpgRuntimeApp.tsx new file mode 100644 index 00000000..4f1e9bc5 --- /dev/null +++ b/src/RpgRuntimeApp.tsx @@ -0,0 +1,45 @@ +import { useEffect, useRef } from 'react'; + +import { RpgRuntimeShell } from './components/rpg-runtime-shell/RpgRuntimeShell'; +import { useRpgRuntimeSession } from './hooks/rpg-session/useRpgRuntimeSession'; +import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes'; +import type { CustomWorldProfile } from './types'; + +export type RpgRuntimeAppIntent = + | { + token: number; + kind: 'custom-world'; + profile: CustomWorldProfile; + } + | { + token: number; + kind: 'snapshot'; + snapshot: HydratedSavedGameSnapshot | null; + }; + +export function RpgRuntimeApp({ + initialIntent, +}: { + initialIntent: RpgRuntimeAppIntent | null; +}) { + const gameShellProps = useRpgRuntimeSession(); + const handledIntentTokenRef = useRef(null); + + useEffect(() => { + if (!initialIntent || handledIntentTokenRef.current === initialIntent.token) { + return; + } + + handledIntentTokenRef.current = initialIntent.token; + if (initialIntent.kind === 'custom-world') { + gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile); + return; + } + + gameShellProps.entry.handleContinueGame(initialIntent.snapshot); + }, [gameShellProps.entry, initialIntent]); + + return ; +} + +export default RpgRuntimeApp; diff --git a/src/components/platform-entry/platformEntryTypes.ts b/src/components/platform-entry/platformEntryTypes.ts index 081c98a8..0b19b98d 100644 --- a/src/components/platform-entry/platformEntryTypes.ts +++ b/src/components/platform-entry/platformEntryTypes.ts @@ -2,7 +2,7 @@ import type { CustomWorldAgentSessionSnapshot, } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; -import type { CustomWorldProfile, GameState } from '../../types'; +import type { CustomWorldProfile } from '../../types'; export type SelectionStage = | 'platform' @@ -34,7 +34,6 @@ export type SyncedAgentDraftResult = { export type PlatformEntryFlowShellProps = { selectionStage: SelectionStage; setSelectionStage: (stage: SelectionStage) => void; - gameState: GameState; hasSavedGame: boolean; savedSnapshot: HydratedSavedGameSnapshot | null; handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void; diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 0ce3b96f..e032724b 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -21,7 +21,6 @@ import type { AuthUser } from '../../services/authService'; import { ApiClientError } from '../../services/apiClient'; import { clearRpgProfileBrowseHistory as clearProfileBrowseHistory, - deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetail, getRpgProfileDashboard as getProfileDashboard, listRpgEntryWorldGallery, @@ -47,8 +46,10 @@ import { listPuzzleGallery, } from '../../services/puzzle-gallery'; import { listPuzzleWorks } from '../../services/puzzle-works'; -import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient'; -import type { GameState } from '../../types'; +import { + deleteRpgEntryWorldProfile, + getRpgEntryWorldGalleryDetailByCode, +} from '../../services/rpg-entry/rpgEntryLibraryClient'; import { AuthUiContext, type PlatformSettingsSection, @@ -130,6 +131,7 @@ vi.mock('../../services/puzzle-gallery', () => ({ })); vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({ + deleteRpgEntryWorldProfile: vi.fn(), getRpgEntryWorldGalleryDetailByCode: vi.fn(), })); @@ -522,7 +524,6 @@ function TestWrapper({ {})} @@ -574,7 +575,7 @@ beforeEach(() => { savedAt: '2026-04-19T12:00:00.000Z', bottomTab: 'adventure', currentStory: null, - gameState: {} as GameState, + gameState: {}, } as HydratedSavedGameSnapshot, }); vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]); @@ -1477,10 +1478,13 @@ test('published puzzle detail returns to the source platform tab', async () => { await waitFor(() => { expect(document.getElementById('platform-tab-panel-category')).toBeTruthy(); }); + await waitFor(() => { + const categoryPanel = getPlatformTabPanel('category'); + expect( + within(categoryPanel).getAllByText('星桥机关').length, + ).toBeGreaterThan(0); + }); const categoryPanel = getPlatformTabPanel('category'); - expect( - within(categoryPanel).getAllByText('星桥机关').length, - ).toBeGreaterThan(0); await user.click( within(categoryPanel).getByRole('button', { @@ -2114,7 +2118,6 @@ test('agent draft result publishes to gallery from publish panel', async () => { {}} @@ -2189,7 +2192,6 @@ test('agent draft result test button enters current draft without publish gate', {}} @@ -2827,7 +2829,7 @@ test('save tab can resume a selected archive directly into the game', async () = currentStory: null, gameState: { worldType: 'CUSTOM', - } as GameState, + }, } as HydratedSavedGameSnapshot, }); diff --git a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts index 1b65edf4..d3d5c2f4 100644 --- a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts +++ b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts @@ -15,7 +15,7 @@ import { listRpgEntryWorldLibrary, publishRpgEntryWorldProfile, unpublishRpgEntryWorldProfile, -} from '../../services/rpg-entry'; +} from '../../services/rpg-entry/rpgEntryLibraryClient'; import { ApiClientError } from '../../services/apiClient'; import type { CustomWorldProfile } from '../../types'; import { diff --git a/src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx b/src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx index 1b580d31..15f4deef 100644 --- a/src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx +++ b/src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx @@ -172,7 +172,6 @@ export function RpgRuntimeStageRouter({ { - if (!authenticatedUserId) { + if (!authenticatedUserId && !snapshotOverride) { return false; }