This commit is contained in:
@@ -16,6 +16,16 @@
|
|||||||
6. 启动测试运行态
|
6. 启动测试运行态
|
||||||
7. 后端推进摇杆输入、刷怪、吞噬收编、三合一、屏外清理和胜负裁决
|
7. 后端推进摇杆输入、刷怪、吞噬收编、三合一、屏外清理和胜负裁决
|
||||||
|
|
||||||
|
### 1.1 2026-04-27 公开游玩次数补充
|
||||||
|
|
||||||
|
正式发布的大鱼吃小鱼作品需要记录公开游玩次数,落地口径如下:
|
||||||
|
|
||||||
|
1. `big_fish_creation_session.play_count` 保存该作品被正式启动的次数,默认值为 `0`。
|
||||||
|
2. 只有平台作品详情、作品架等正式入口启动已发布作品时递增;创作结果页内的测试运行不计入。
|
||||||
|
3. 前端作品摘要 contract 暴露 `playCount`,作品架展示与拼图一致使用该后端值。
|
||||||
|
4. 本轮仅记录“进入玩法”次数,不记录大鱼吃小鱼总时长;个人 profile 的 RPG 时长统计仍由 runtime snapshot 负责。
|
||||||
|
5. schema 变更需要同步 `migration.rs` 已纳入的 `big_fish_creation_session` 导入导出结构。
|
||||||
|
|
||||||
## 2. 本轮明确不做
|
## 2. 本轮明确不做
|
||||||
|
|
||||||
1. 不在本文件内展开正式图片模型链、OSS 真相链和占位兼容层的细节;相关正式出图方案以 `BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md` 为准。
|
1. 不在本文件内展开正式图片模型链、OSS 真相链和占位兼容层的细节;相关正式出图方案以 `BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md` 为准。
|
||||||
|
|||||||
@@ -377,6 +377,17 @@ Node 侧入口位于:
|
|||||||
|
|
||||||
这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。
|
这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。
|
||||||
|
|
||||||
|
## 10.1 2026-04-27 统计写链修正
|
||||||
|
|
||||||
|
`runtime_snapshot / save archive` 主链已接入后,profile projection 的写入语义补充冻结如下:
|
||||||
|
|
||||||
|
1. 正式 RPG 游玩只通过 `PUT /api/runtime/save/snapshot` 刷新 `profile_dashboard_state` 与 `profile_played_world`。
|
||||||
|
2. `runtimeMode = "preview"`、`runtimeMode = "test"` 或 `runtimePersistenceDisabled = true` 的快照不刷新 profile projection。
|
||||||
|
3. 前端发起自动保存与手动保存前,必须先把 `runtimeStats.lastPlayTickAt` 到当前时间的 live 时长同步进 `runtimeStats.playTimeMs`,避免 15 秒内进入又退出时保存 0。
|
||||||
|
4. `profile_played_world` 的一行表示“当前用户玩过这个世界”,不是全站作品热度计数;`playedWorldCount` 读取当前用户的去重世界数。
|
||||||
|
5. `profile_dashboard_state.total_play_time_ms` 通过同一用户同一世界的 `runtimeStats.playTimeMs - last_observed_play_time_ms` 增量累积,后端使用 `saturating_sub` 防止旧快照回退导致负增量。
|
||||||
|
6. 作品卡上的公开热度计数如果需要覆盖 RPG 作品,应另立公开作品统计方案;不能把个人 `profile_played_world` 误当成全站作品 `playCount`。
|
||||||
|
|
||||||
## 11. 测试策略
|
## 11. 测试策略
|
||||||
|
|
||||||
### 11.1 必跑
|
### 11.1 必跑
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface BigFishWorkSummary {
|
|||||||
levelMainImageReadyCount: number;
|
levelMainImageReadyCount: number;
|
||||||
levelMotionReadyCount: number;
|
levelMotionReadyCount: number;
|
||||||
backgroundReady: boolean;
|
backgroundReady: boolean;
|
||||||
|
playCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BigFishWorksResponse {
|
export interface BigFishWorksResponse {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ use crate::{
|
|||||||
big_fish::{
|
big_fish::{
|
||||||
create_big_fish_session, delete_big_fish_work, execute_big_fish_action,
|
create_big_fish_session, delete_big_fish_work, execute_big_fish_action,
|
||||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery, stream_big_fish_message,
|
get_big_fish_session, get_big_fish_works, list_big_fish_gallery, stream_big_fish_message,
|
||||||
submit_big_fish_message,
|
record_big_fish_play, submit_big_fish_message,
|
||||||
},
|
},
|
||||||
character_animation_assets::{
|
character_animation_assets::{
|
||||||
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
||||||
@@ -83,8 +83,7 @@ use crate::{
|
|||||||
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
|
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
|
||||||
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
|
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
|
||||||
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
|
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
|
||||||
submit_puzzle_leaderboard,
|
submit_puzzle_leaderboard, swap_puzzle_pieces,
|
||||||
swap_puzzle_pieces,
|
|
||||||
},
|
},
|
||||||
refresh_session::refresh_session,
|
refresh_session::refresh_session,
|
||||||
request_context::{attach_request_context, resolve_request_id},
|
request_context::{attach_request_context, resolve_request_id},
|
||||||
@@ -575,6 +574,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
require_bearer_auth,
|
require_bearer_auth,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/works/{session_id}/play",
|
||||||
|
post(record_big_fish_play),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/puzzle/agent/sessions",
|
"/api/runtime/puzzle/agent/sessions",
|
||||||
post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state(
|
post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
@@ -191,6 +191,32 @@ pub async fn delete_big_fish_work(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn record_big_fish_play(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(session_id): Path<String>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||||||
|
|
||||||
|
let items = state
|
||||||
|
.spacetime_client()
|
||||||
|
.record_big_fish_play(session_id, current_utc_micros())
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(json_success_body(
|
||||||
|
Some(&request_context),
|
||||||
|
BigFishWorksResponse {
|
||||||
|
items: items
|
||||||
|
.into_iter()
|
||||||
|
.map(map_big_fish_work_summary_response)
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn submit_big_fish_message(
|
pub async fn submit_big_fish_message(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(session_id): Path<String>,
|
Path(session_id): Path<String>,
|
||||||
@@ -924,6 +950,7 @@ fn map_big_fish_work_summary_response(
|
|||||||
level_main_image_ready_count: item.level_main_image_ready_count,
|
level_main_image_ready_count: item.level_main_image_ready_count,
|
||||||
level_motion_ready_count: item.level_motion_ready_count,
|
level_motion_ready_count: item.level_motion_ready_count,
|
||||||
background_ready: item.background_ready,
|
background_ready: item.background_ready,
|
||||||
|
play_count: item.play_count,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ pub struct BigFishWorkSummarySnapshot {
|
|||||||
pub level_main_image_ready_count: u32,
|
pub level_main_image_ready_count: u32,
|
||||||
pub level_motion_ready_count: u32,
|
pub level_motion_ready_count: u32,
|
||||||
pub background_ready: bool,
|
pub background_ready: bool,
|
||||||
|
pub play_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
@@ -316,6 +317,13 @@ pub struct BigFishPublishInput {
|
|||||||
pub published_at_micros: i64,
|
pub published_at_micros: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BigFishPlayRecordInput {
|
||||||
|
pub session_id: String,
|
||||||
|
pub played_at_micros: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum BigFishFieldError {
|
pub enum BigFishFieldError {
|
||||||
MissingSessionId,
|
MissingSessionId,
|
||||||
@@ -654,6 +662,13 @@ pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFish
|
|||||||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> {
|
||||||
|
if normalize_required_string(&input.session_id).is_none() {
|
||||||
|
return Err(BigFishFieldError::MissingSessionId);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
|
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
|
||||||
serde_json::to_string(anchor_pack)
|
serde_json::to_string(anchor_pack)
|
||||||
}
|
}
|
||||||
@@ -861,5 +876,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(coverage.blockers.iter().any(|item| item.contains("背景图")));
|
assert!(coverage.blockers.iter().any(|item| item.contains("背景图")));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1964,14 +1964,18 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) ->
|
|||||||
if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared {
|
if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared {
|
||||||
let cleared_at_ms = current_unix_ms();
|
let cleared_at_ms = current_unix_ms();
|
||||||
current_level.cleared_at_ms = Some(cleared_at_ms);
|
current_level.cleared_at_ms = Some(cleared_at_ms);
|
||||||
current_level.elapsed_ms =
|
current_level.elapsed_ms = Some(
|
||||||
Some(cleared_at_ms.saturating_sub(current_level.started_at_ms).max(1_000));
|
cleared_at_ms
|
||||||
|
.saturating_sub(current_level.started_at_ms)
|
||||||
|
.max(1_000),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
current_level.status = next_level_status;
|
current_level.status = next_level_status;
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_cleared && run.current_level.as_ref().map(|level| level.status)
|
if is_cleared
|
||||||
!= Some(PuzzleRuntimeLevelStatus::Cleared)
|
&& run.current_level.as_ref().map(|level| level.status)
|
||||||
|
!= Some(PuzzleRuntimeLevelStatus::Cleared)
|
||||||
{
|
{
|
||||||
next_run.cleared_level_count += 1;
|
next_run.cleared_level_count += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub struct BigFishWorkSummaryResponse {
|
|||||||
pub level_main_image_ready_count: u32,
|
pub level_main_image_ready_count: u32,
|
||||||
pub level_motion_ready_count: u32,
|
pub level_motion_ready_count: u32,
|
||||||
pub background_ready: bool,
|
pub background_ready: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub play_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::mapper::*;
|
use crate::mapper::*;
|
||||||
use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work;
|
use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work;
|
||||||
|
use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play;
|
||||||
|
|
||||||
impl SpacetimeClient {
|
impl SpacetimeClient {
|
||||||
pub async fn create_big_fish_session(
|
pub async fn create_big_fish_session(
|
||||||
@@ -131,6 +132,30 @@ impl SpacetimeClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn record_big_fish_play(
|
||||||
|
&self,
|
||||||
|
session_id: String,
|
||||||
|
played_at_micros: i64,
|
||||||
|
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
|
||||||
|
let procedure_input = BigFishPlayRecordInput {
|
||||||
|
session_id,
|
||||||
|
played_at_micros,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.call_after_connect(move |connection, sender| {
|
||||||
|
connection.procedures().record_big_fish_play_then(
|
||||||
|
procedure_input,
|
||||||
|
move |_, result| {
|
||||||
|
let mapped = result
|
||||||
|
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||||
|
.and_then(map_big_fish_works_procedure_result);
|
||||||
|
send_once(&sender, mapped);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn submit_big_fish_message(
|
pub async fn submit_big_fish_message(
|
||||||
&self,
|
&self,
|
||||||
input: BigFishMessageSubmitRecordInput,
|
input: BigFishMessageSubmitRecordInput,
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ pub use mapper::{
|
|||||||
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
||||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput,
|
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
||||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
|
PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord,
|
||||||
PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||||
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
|
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
|
||||||
ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
|
ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4606,6 +4606,7 @@ pub struct BigFishWorkSummaryRecord {
|
|||||||
pub level_main_image_ready_count: u32,
|
pub level_main_image_ready_count: u32,
|
||||||
pub level_motion_ready_count: u32,
|
pub level_motion_ready_count: u32,
|
||||||
pub background_ready: bool,
|
pub background_ready: bool,
|
||||||
|
pub play_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub struct BigFishCreationSession {
|
|||||||
pub asset_coverage_json: String,
|
pub asset_coverage_json: String,
|
||||||
pub last_assistant_reply: Option<String>,
|
pub last_assistant_reply: Option<String>,
|
||||||
pub publish_ready: bool,
|
pub publish_ready: bool,
|
||||||
|
pub play_count: u32,
|
||||||
pub created_at: __sdk::Timestamp,
|
pub created_at: __sdk::Timestamp,
|
||||||
pub updated_at: __sdk::Timestamp,
|
pub updated_at: __sdk::Timestamp,
|
||||||
}
|
}
|
||||||
@@ -43,6 +44,7 @@ pub struct BigFishCreationSessionCols {
|
|||||||
pub asset_coverage_json: __sdk::__query_builder::Col<BigFishCreationSession, String>,
|
pub asset_coverage_json: __sdk::__query_builder::Col<BigFishCreationSession, String>,
|
||||||
pub last_assistant_reply: __sdk::__query_builder::Col<BigFishCreationSession, Option<String>>,
|
pub last_assistant_reply: __sdk::__query_builder::Col<BigFishCreationSession, Option<String>>,
|
||||||
pub publish_ready: __sdk::__query_builder::Col<BigFishCreationSession, bool>,
|
pub publish_ready: __sdk::__query_builder::Col<BigFishCreationSession, bool>,
|
||||||
|
pub play_count: __sdk::__query_builder::Col<BigFishCreationSession, u32>,
|
||||||
pub created_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
|
pub created_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
|
||||||
pub updated_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
|
pub updated_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
|
||||||
}
|
}
|
||||||
@@ -68,6 +70,7 @@ impl __sdk::__query_builder::HasCols for BigFishCreationSession {
|
|||||||
"last_assistant_reply",
|
"last_assistant_reply",
|
||||||
),
|
),
|
||||||
publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"),
|
publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"),
|
||||||
|
play_count: __sdk::__query_builder::Col::new(table_name, "play_count"),
|
||||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
#![allow(unused, clippy::all)]
|
||||||
|
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||||
|
|
||||||
|
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||||
|
#[sats(crate = __lib)]
|
||||||
|
pub struct BigFishPlayRecordInput {
|
||||||
|
pub session_id: String,
|
||||||
|
pub played_at_micros: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for BigFishPlayRecordInput {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ pub mod big_fish_game_draft_type;
|
|||||||
pub mod big_fish_level_blueprint_type;
|
pub mod big_fish_level_blueprint_type;
|
||||||
pub mod big_fish_message_finalize_input_type;
|
pub mod big_fish_message_finalize_input_type;
|
||||||
pub mod big_fish_message_submit_input_type;
|
pub mod big_fish_message_submit_input_type;
|
||||||
|
pub mod big_fish_play_record_input_type;
|
||||||
pub mod big_fish_publish_input_type;
|
pub mod big_fish_publish_input_type;
|
||||||
pub mod big_fish_runtime_params_type;
|
pub mod big_fish_runtime_params_type;
|
||||||
pub mod big_fish_session_create_input_type;
|
pub mod big_fish_session_create_input_type;
|
||||||
@@ -331,6 +332,7 @@ pub mod quest_objective_snapshot_type;
|
|||||||
pub mod quest_progress_signal_type;
|
pub mod quest_progress_signal_type;
|
||||||
pub mod quest_record_input_type;
|
pub mod quest_record_input_type;
|
||||||
pub mod quest_record_type;
|
pub mod quest_record_type;
|
||||||
|
pub mod record_big_fish_play_procedure;
|
||||||
pub mod quest_reward_equipment_slot_type;
|
pub mod quest_reward_equipment_slot_type;
|
||||||
pub mod quest_reward_intel_type;
|
pub mod quest_reward_intel_type;
|
||||||
pub mod quest_reward_item_rarity_type;
|
pub mod quest_reward_item_rarity_type;
|
||||||
@@ -558,6 +560,7 @@ pub use big_fish_game_draft_type::BigFishGameDraft;
|
|||||||
pub use big_fish_level_blueprint_type::BigFishLevelBlueprint;
|
pub use big_fish_level_blueprint_type::BigFishLevelBlueprint;
|
||||||
pub use big_fish_message_finalize_input_type::BigFishMessageFinalizeInput;
|
pub use big_fish_message_finalize_input_type::BigFishMessageFinalizeInput;
|
||||||
pub use big_fish_message_submit_input_type::BigFishMessageSubmitInput;
|
pub use big_fish_message_submit_input_type::BigFishMessageSubmitInput;
|
||||||
|
pub use big_fish_play_record_input_type::BigFishPlayRecordInput;
|
||||||
pub use big_fish_publish_input_type::BigFishPublishInput;
|
pub use big_fish_publish_input_type::BigFishPublishInput;
|
||||||
pub use big_fish_runtime_params_type::BigFishRuntimeParams;
|
pub use big_fish_runtime_params_type::BigFishRuntimeParams;
|
||||||
pub use big_fish_session_create_input_type::BigFishSessionCreateInput;
|
pub use big_fish_session_create_input_type::BigFishSessionCreateInput;
|
||||||
@@ -800,6 +803,7 @@ pub use quest_objective_snapshot_type::QuestObjectiveSnapshot;
|
|||||||
pub use quest_progress_signal_type::QuestProgressSignal;
|
pub use quest_progress_signal_type::QuestProgressSignal;
|
||||||
pub use quest_record_input_type::QuestRecordInput;
|
pub use quest_record_input_type::QuestRecordInput;
|
||||||
pub use quest_record_type::QuestRecord;
|
pub use quest_record_type::QuestRecord;
|
||||||
|
pub use record_big_fish_play_procedure::record_big_fish_play;
|
||||||
pub use quest_reward_equipment_slot_type::QuestRewardEquipmentSlot;
|
pub use quest_reward_equipment_slot_type::QuestRewardEquipmentSlot;
|
||||||
pub use quest_reward_intel_type::QuestRewardIntel;
|
pub use quest_reward_intel_type::QuestRewardIntel;
|
||||||
pub use quest_reward_item_rarity_type::QuestRewardItemRarity;
|
pub use quest_reward_item_rarity_type::QuestRewardItemRarity;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
#![allow(unused, clippy::all)]
|
||||||
|
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||||
|
|
||||||
|
use super::big_fish_play_record_input_type::BigFishPlayRecordInput;
|
||||||
|
use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult;
|
||||||
|
|
||||||
|
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||||
|
#[sats(crate = __lib)]
|
||||||
|
struct RecordBigFishPlayArgs {
|
||||||
|
pub input: BigFishPlayRecordInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for RecordBigFishPlayArgs {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
/// Extension trait for access to the procedure `record_big_fish_play`.
|
||||||
|
///
|
||||||
|
/// Implemented for [`super::RemoteProcedures`].
|
||||||
|
pub trait record_big_fish_play {
|
||||||
|
fn record_big_fish_play(&self, input: BigFishPlayRecordInput) {
|
||||||
|
self.record_big_fish_play_then(input, |_, _| {});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_big_fish_play_then(
|
||||||
|
&self,
|
||||||
|
input: BigFishPlayRecordInput,
|
||||||
|
|
||||||
|
__callback: impl FnOnce(
|
||||||
|
&super::ProcedureEventContext,
|
||||||
|
Result<BigFishWorksProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl record_big_fish_play for super::RemoteProcedures {
|
||||||
|
fn record_big_fish_play_then(
|
||||||
|
&self,
|
||||||
|
input: BigFishPlayRecordInput,
|
||||||
|
|
||||||
|
__callback: impl FnOnce(
|
||||||
|
&super::ProcedureEventContext,
|
||||||
|
Result<BigFishWorksProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
) {
|
||||||
|
self.imp
|
||||||
|
.invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>(
|
||||||
|
"record_big_fish_play",
|
||||||
|
RecordBigFishPlayArgs { input },
|
||||||
|
__callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -478,15 +478,14 @@ impl SpacetimeClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.call_after_connect(move |connection, sender| {
|
self.call_after_connect(move |connection, sender| {
|
||||||
connection.procedures().submit_puzzle_leaderboard_entry_then(
|
connection
|
||||||
procedure_input,
|
.procedures()
|
||||||
move |_, result| {
|
.submit_puzzle_leaderboard_entry_then(procedure_input, move |_, result| {
|
||||||
let mapped = result
|
let mapped = result
|
||||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||||
.and_then(map_puzzle_run_procedure_result);
|
.and_then(map_puzzle_run_procedure_result);
|
||||||
send_once(&sender, mapped);
|
send_once(&sender, mapped);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ pub(crate) fn generate_big_fish_asset_tx(
|
|||||||
.map_err(|error| error.to_string())?,
|
.map_err(|error| error.to_string())?,
|
||||||
last_assistant_reply: Some(reply.clone()),
|
last_assistant_reply: Some(reply.clone()),
|
||||||
publish_ready: coverage.publish_ready,
|
publish_ready: coverage.publish_ready,
|
||||||
|
play_count: session.play_count,
|
||||||
created_at: session.created_at,
|
created_at: session.created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
};
|
};
|
||||||
@@ -164,6 +165,7 @@ pub(crate) fn publish_big_fish_game_tx(
|
|||||||
.map_err(|error| error.to_string())?,
|
.map_err(|error| error.to_string())?,
|
||||||
last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()),
|
last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()),
|
||||||
publish_ready: true,
|
publish_ready: true,
|
||||||
|
play_count: session.play_count,
|
||||||
created_at: session.created_at,
|
created_at: session.created_at,
|
||||||
updated_at: published_at,
|
updated_at: published_at,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -93,6 +93,32 @@ pub fn delete_big_fish_work(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::procedure]
|
||||||
|
pub fn record_big_fish_play(
|
||||||
|
ctx: &mut ProcedureContext,
|
||||||
|
input: BigFishPlayRecordInput,
|
||||||
|
) -> BigFishWorksProcedureResult {
|
||||||
|
match ctx.try_with_tx(|tx| record_big_fish_play_tx(tx, input.clone())) {
|
||||||
|
Ok(items) => match serde_json::to_string(&items) {
|
||||||
|
Ok(items_json) => BigFishWorksProcedureResult {
|
||||||
|
ok: true,
|
||||||
|
items_json: Some(items_json),
|
||||||
|
error_message: None,
|
||||||
|
},
|
||||||
|
Err(error) => BigFishWorksProcedureResult {
|
||||||
|
ok: false,
|
||||||
|
items_json: None,
|
||||||
|
error_message: Some(error.to_string()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(message) => BigFishWorksProcedureResult {
|
||||||
|
ok: false,
|
||||||
|
items_json: None,
|
||||||
|
error_message: Some(message),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[spacetimedb::procedure]
|
#[spacetimedb::procedure]
|
||||||
pub fn submit_big_fish_message(
|
pub fn submit_big_fish_message(
|
||||||
ctx: &mut ProcedureContext,
|
ctx: &mut ProcedureContext,
|
||||||
@@ -194,6 +220,7 @@ pub(crate) fn create_big_fish_session_tx(
|
|||||||
.map_err(|error| error.to_string())?,
|
.map_err(|error| error.to_string())?,
|
||||||
last_assistant_reply: Some(input.welcome_message_text.clone()),
|
last_assistant_reply: Some(input.welcome_message_text.clone()),
|
||||||
publish_ready: false,
|
publish_ready: false,
|
||||||
|
play_count: 0,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at: created_at,
|
updated_at: created_at,
|
||||||
});
|
});
|
||||||
@@ -383,6 +410,7 @@ pub(crate) fn submit_big_fish_message_tx(
|
|||||||
asset_coverage_json: session.asset_coverage_json.clone(),
|
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||||
last_assistant_reply: session.last_assistant_reply.clone(),
|
last_assistant_reply: session.last_assistant_reply.clone(),
|
||||||
publish_ready: session.publish_ready,
|
publish_ready: session.publish_ready,
|
||||||
|
play_count: session.play_count,
|
||||||
created_at: session.created_at,
|
created_at: session.created_at,
|
||||||
updated_at: submitted_at,
|
updated_at: submitted_at,
|
||||||
};
|
};
|
||||||
@@ -429,6 +457,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx(
|
|||||||
asset_coverage_json: session.asset_coverage_json.clone(),
|
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||||
last_assistant_reply: session.last_assistant_reply.clone(),
|
last_assistant_reply: session.last_assistant_reply.clone(),
|
||||||
publish_ready: session.publish_ready,
|
publish_ready: session.publish_ready,
|
||||||
|
play_count: session.play_count,
|
||||||
created_at: session.created_at,
|
created_at: session.created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
};
|
};
|
||||||
@@ -483,6 +512,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx(
|
|||||||
asset_coverage_json: session.asset_coverage_json.clone(),
|
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||||
last_assistant_reply: Some(assistant_reply_text),
|
last_assistant_reply: Some(assistant_reply_text),
|
||||||
publish_ready: session.publish_ready,
|
publish_ready: session.publish_ready,
|
||||||
|
play_count: session.play_count,
|
||||||
created_at: session.created_at,
|
created_at: session.created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
};
|
};
|
||||||
@@ -530,6 +560,7 @@ pub(crate) fn compile_big_fish_draft_tx(
|
|||||||
.map_err(|error| error.to_string())?,
|
.map_err(|error| error.to_string())?,
|
||||||
last_assistant_reply: Some(reply.clone()),
|
last_assistant_reply: Some(reply.clone()),
|
||||||
publish_ready: coverage.publish_ready,
|
publish_ready: coverage.publish_ready,
|
||||||
|
play_count: session.play_count,
|
||||||
created_at: session.created_at,
|
created_at: session.created_at,
|
||||||
updated_at: compiled_at,
|
updated_at: compiled_at,
|
||||||
};
|
};
|
||||||
@@ -657,9 +688,51 @@ pub(crate) fn build_big_fish_work_summary(
|
|||||||
level_main_image_ready_count: coverage.level_main_image_ready_count,
|
level_main_image_ready_count: coverage.level_main_image_ready_count,
|
||||||
level_motion_ready_count: coverage.level_motion_ready_count,
|
level_motion_ready_count: coverage.level_motion_ready_count,
|
||||||
background_ready: coverage.background_ready,
|
background_ready: coverage.background_ready,
|
||||||
|
play_count: row.play_count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_big_fish_play_tx(
|
||||||
|
ctx: &ReducerContext,
|
||||||
|
input: BigFishPlayRecordInput,
|
||||||
|
) -> Result<Vec<BigFishWorkSummarySnapshot>, String> {
|
||||||
|
validate_play_record_input(&input).map_err(|error| error.to_string())?;
|
||||||
|
let session = ctx
|
||||||
|
.db
|
||||||
|
.big_fish_creation_session()
|
||||||
|
.session_id()
|
||||||
|
.find(&input.session_id)
|
||||||
|
.filter(|row| row.stage == BigFishCreationStage::Published)
|
||||||
|
.ok_or_else(|| "big_fish 已发布作品不存在".to_string())?;
|
||||||
|
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
|
||||||
|
let next_session = BigFishCreationSession {
|
||||||
|
session_id: session.session_id.clone(),
|
||||||
|
owner_user_id: session.owner_user_id.clone(),
|
||||||
|
seed_text: session.seed_text.clone(),
|
||||||
|
current_turn: session.current_turn,
|
||||||
|
progress_percent: session.progress_percent,
|
||||||
|
stage: session.stage,
|
||||||
|
anchor_pack_json: session.anchor_pack_json.clone(),
|
||||||
|
draft_json: session.draft_json.clone(),
|
||||||
|
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||||
|
last_assistant_reply: session.last_assistant_reply.clone(),
|
||||||
|
publish_ready: session.publish_ready,
|
||||||
|
// 中文注释:这里只记录正式发布作品的进入次数,创作结果页测试运行不走这个 procedure。
|
||||||
|
play_count: session.play_count.saturating_add(1),
|
||||||
|
created_at: session.created_at,
|
||||||
|
updated_at: played_at,
|
||||||
|
};
|
||||||
|
replace_big_fish_session(ctx, &session, next_session);
|
||||||
|
|
||||||
|
list_big_fish_works_tx(
|
||||||
|
ctx,
|
||||||
|
BigFishWorksListInput {
|
||||||
|
owner_user_id: String::new(),
|
||||||
|
published_only: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn replace_big_fish_session(
|
pub(crate) fn replace_big_fish_session(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
current: &BigFishCreationSession,
|
current: &BigFishCreationSession,
|
||||||
@@ -693,6 +766,7 @@ mod tests {
|
|||||||
asset_coverage_json: "{}".to_string(),
|
asset_coverage_json: "{}".to_string(),
|
||||||
last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()),
|
last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()),
|
||||||
publish_ready: false,
|
publish_ready: false,
|
||||||
|
play_count: 0,
|
||||||
created_at: Timestamp::from_micros_since_unix_epoch(1),
|
created_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||||
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub struct BigFishCreationSession {
|
|||||||
pub(crate) asset_coverage_json: String,
|
pub(crate) asset_coverage_json: String,
|
||||||
pub(crate) last_assistant_reply: Option<String>,
|
pub(crate) last_assistant_reply: Option<String>,
|
||||||
pub(crate) publish_ready: bool,
|
pub(crate) publish_ready: bool,
|
||||||
|
pub(crate) play_count: u32,
|
||||||
pub(crate) created_at: Timestamp,
|
pub(crate) created_at: Timestamp,
|
||||||
pub(crate) updated_at: Timestamp,
|
pub(crate) updated_at: Timestamp,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -659,6 +659,19 @@ where
|
|||||||
Ok(wrapped.0)
|
Ok(wrapped.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value {
|
||||||
|
let mut next_value = value.clone();
|
||||||
|
if table_name == "big_fish_creation_session" {
|
||||||
|
if let Some(object) = next_value.as_object_mut() {
|
||||||
|
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。
|
||||||
|
object
|
||||||
|
.entry("play_count".to_string())
|
||||||
|
.or_insert_with(|| serde_json::Value::from(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next_value
|
||||||
|
}
|
||||||
|
|
||||||
fn insert_migration_table_rows(
|
fn insert_migration_table_rows(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
table: &MigrationTable,
|
table: &MigrationTable,
|
||||||
@@ -672,7 +685,8 @@ fn insert_migration_table_rows(
|
|||||||
let mut imported = 0u64;
|
let mut imported = 0u64;
|
||||||
let mut skipped = 0u64;
|
let mut skipped = 0u64;
|
||||||
for value in &table.rows {
|
for value in &table.rows {
|
||||||
let row = row_from_json(value)
|
let normalized_value = normalize_migration_row(stringify!($table), value);
|
||||||
|
let row = row_from_json(&normalized_value)
|
||||||
.map_err(|error| format!("{}: {error}", stringify!($table)))?;
|
.map_err(|error| format!("{}: {error}", stringify!($table)))?;
|
||||||
let insert_result = ctx.db
|
let insert_result = ctx.db
|
||||||
.$table()
|
.$table()
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ use module_puzzle::{
|
|||||||
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
|
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
|
||||||
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
|
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
|
||||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
|
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
|
||||||
PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft,
|
PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput,
|
||||||
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleRunDragInput, PuzzleRunGetInput,
|
PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput,
|
||||||
PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput,
|
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot,
|
||||||
PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput,
|
PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput,
|
||||||
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
|
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
|
||||||
PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
|
PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
|
||||||
apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview,
|
apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview,
|
||||||
@@ -1689,12 +1689,7 @@ fn upsert_puzzle_leaderboard_entry(
|
|||||||
) {
|
) {
|
||||||
let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size);
|
let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size);
|
||||||
let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros);
|
let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros);
|
||||||
if let Some(existing) = ctx
|
if let Some(existing) = ctx.db.puzzle_leaderboard_entry().entry_id().find(&entry_id) {
|
||||||
.db
|
|
||||||
.puzzle_leaderboard_entry()
|
|
||||||
.entry_id()
|
|
||||||
.find(&entry_id)
|
|
||||||
{
|
|
||||||
let should_replace = elapsed_ms < existing.best_elapsed_ms
|
let should_replace = elapsed_ms < existing.best_elapsed_ms
|
||||||
|| (elapsed_ms == existing.best_elapsed_ms
|
|| (elapsed_ms == existing.best_elapsed_ms
|
||||||
&& updated_at.to_micros_since_unix_epoch()
|
&& updated_at.to_micros_since_unix_epoch()
|
||||||
@@ -1725,16 +1720,18 @@ fn upsert_puzzle_leaderboard_entry(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.db.puzzle_leaderboard_entry().insert(PuzzleLeaderboardEntryRow {
|
ctx.db
|
||||||
entry_id,
|
.puzzle_leaderboard_entry()
|
||||||
profile_id: profile_id.to_string(),
|
.insert(PuzzleLeaderboardEntryRow {
|
||||||
grid_size,
|
entry_id,
|
||||||
user_id: user_id.to_string(),
|
profile_id: profile_id.to_string(),
|
||||||
nickname: nickname.to_string(),
|
grid_size,
|
||||||
best_elapsed_ms: elapsed_ms,
|
user_id: user_id.to_string(),
|
||||||
last_run_id: run_id.to_string(),
|
nickname: nickname.to_string(),
|
||||||
updated_at,
|
best_elapsed_ms: elapsed_ms,
|
||||||
});
|
last_run_id: run_id.to_string(),
|
||||||
|
updated_at,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_puzzle_leaderboard_entries(
|
fn list_puzzle_leaderboard_entries(
|
||||||
@@ -1799,8 +1796,8 @@ fn deserialize_run(value: &str) -> Result<PuzzleRunSnapshot, String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use module_puzzle::{
|
use module_puzzle::{
|
||||||
build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score,
|
PuzzleLeaderboardEntry, build_generated_candidates, empty_anchor_pack,
|
||||||
PuzzleLeaderboardEntry,
|
recommendation_score, tag_similarity_score,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ function mapBigFishWorkToShelfItem(
|
|||||||
id: 'level-motion-ready-count',
|
id: 'level-motion-ready-count',
|
||||||
label: `动作 ${item.levelMotionReadyCount}`,
|
label: `动作 ${item.levelMotionReadyCount}`,
|
||||||
},
|
},
|
||||||
|
{ id: 'play-count', label: `游玩 ${item.playCount ?? 0}` },
|
||||||
...(item.backgroundReady
|
...(item.backgroundReady
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
deleteBigFishWork,
|
deleteBigFishWork,
|
||||||
listBigFishWorks,
|
listBigFishWorks,
|
||||||
|
recordBigFishWorkPlay,
|
||||||
} from '../../services/big-fish-works';
|
} from '../../services/big-fish-works';
|
||||||
import {
|
import {
|
||||||
readCustomWorldAgentUiState,
|
readCustomWorldAgentUiState,
|
||||||
@@ -91,6 +92,7 @@ import {
|
|||||||
} from '../../services/puzzle-gallery';
|
} from '../../services/puzzle-gallery';
|
||||||
import {
|
import {
|
||||||
advanceLocalPuzzleNextLevel,
|
advanceLocalPuzzleNextLevel,
|
||||||
|
startPuzzleRun,
|
||||||
submitPuzzleLeaderboard,
|
submitPuzzleLeaderboard,
|
||||||
} from '../../services/puzzle-runtime';
|
} from '../../services/puzzle-runtime';
|
||||||
import {
|
import {
|
||||||
@@ -1147,11 +1149,21 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBigFishError(null);
|
const run = async () => {
|
||||||
setBigFishRuntimeShare(null);
|
setBigFishError(null);
|
||||||
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
setBigFishRuntimeShare(null);
|
||||||
setSelectionStage('big-fish-runtime');
|
if (bigFishSession.stage === 'published') {
|
||||||
}, [bigFishSession, setSelectionStage]);
|
await recordBigFishWorkPlay(bigFishSession.sessionId);
|
||||||
|
await refreshBigFishShelf();
|
||||||
|
}
|
||||||
|
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
||||||
|
setSelectionStage('big-fish-runtime');
|
||||||
|
};
|
||||||
|
|
||||||
|
void run().catch((error) => {
|
||||||
|
setBigFishError(resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'));
|
||||||
|
});
|
||||||
|
}, [bigFishSession, refreshBigFishShelf, resolveBigFishErrorMessage, setSelectionStage]);
|
||||||
|
|
||||||
const restartBigFishRun = useCallback(() => {
|
const restartBigFishRun = useCallback(() => {
|
||||||
if (!bigFishSession && !bigFishRun) {
|
if (!bigFishSession && !bigFishRun) {
|
||||||
@@ -1175,8 +1187,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { item } = await getPuzzleGalleryDetail(profileId);
|
const { item } = await getPuzzleGalleryDetail(profileId);
|
||||||
|
const { run } = await startPuzzleRun({ profileId: item.profileId });
|
||||||
setSelectedPuzzleDetail(item);
|
setSelectedPuzzleDetail(item);
|
||||||
setPuzzleRun(startLocalPuzzleRun(item));
|
setPuzzleRun(run);
|
||||||
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
||||||
setSelectionStage('puzzle-runtime');
|
setSelectionStage('puzzle-runtime');
|
||||||
pushAppHistoryPath(
|
pushAppHistoryPath(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { syncGameStatePlayTime } from '../../data/runtimeStats';
|
||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import { isAbortError } from '../../services/apiClient';
|
import { isAbortError } from '../../services/apiClient';
|
||||||
import { rpgSnapshotClient } from '../../services/rpg-runtime';
|
import { rpgSnapshotClient } from '../../services/rpg-runtime';
|
||||||
@@ -37,6 +38,10 @@ function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPersistedGameState(gameState: GameState) {
|
||||||
|
return syncGameStatePlayTime(gameState);
|
||||||
|
}
|
||||||
|
|
||||||
export type UseRpgSessionPersistenceParams = {
|
export type UseRpgSessionPersistenceParams = {
|
||||||
authenticatedUserId: string | null;
|
authenticatedUserId: string | null;
|
||||||
gameState: GameState;
|
gameState: GameState;
|
||||||
@@ -208,9 +213,10 @@ export function useRpgSessionPersistence({
|
|||||||
if (!canPersist) return;
|
if (!canPersist) return;
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
const persistedGameState = buildPersistedGameState(gameState);
|
||||||
void persistSnapshot({
|
void persistSnapshot({
|
||||||
payload: {
|
payload: {
|
||||||
gameState,
|
gameState: persistedGameState,
|
||||||
bottomTab,
|
bottomTab,
|
||||||
currentStory,
|
currentStory,
|
||||||
},
|
},
|
||||||
@@ -235,9 +241,10 @@ export function useRpgSessionPersistence({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const persistedGameState = buildPersistedGameState(nextGameState);
|
||||||
const snapshot = await persistSnapshot({
|
const snapshot = await persistSnapshot({
|
||||||
payload: {
|
payload: {
|
||||||
gameState: nextGameState,
|
gameState: persistedGameState,
|
||||||
bottomTab: nextBottomTab,
|
bottomTab: nextBottomTab,
|
||||||
currentStory: nextStory,
|
currentStory: nextStory,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -161,3 +161,68 @@ test('unauthenticated runtime skips remote snapshot hydration', async () => {
|
|||||||
expect(screen.getByTestId('saved-game').textContent).toBe('no');
|
expect(screen.getByTestId('saved-game').textContent).toBe('no');
|
||||||
expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled();
|
expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('authenticated runtime autosave syncs live play time before remote snapshot upload', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-04-27T10:00:02.000Z'));
|
||||||
|
storageMocks.putSaveSnapshot.mockResolvedValue({
|
||||||
|
gameState: {},
|
||||||
|
bottomTab: 'adventure',
|
||||||
|
currentStory: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gameState = {
|
||||||
|
runtimePersistenceDisabled: false,
|
||||||
|
runtimeMode: 'play',
|
||||||
|
currentScene: 'Story',
|
||||||
|
worldType: 'CUSTOM',
|
||||||
|
playerCharacter: { id: 'hero-1' },
|
||||||
|
runtimeStats: {
|
||||||
|
playTimeMs: 0,
|
||||||
|
lastPlayTickAt: '2026-04-27T10:00:00.000Z',
|
||||||
|
hostileNpcsDefeated: 0,
|
||||||
|
questsAccepted: 0,
|
||||||
|
itemsUsed: 0,
|
||||||
|
scenesTraveled: 0,
|
||||||
|
},
|
||||||
|
} as GameState;
|
||||||
|
|
||||||
|
function AutosaveHarness() {
|
||||||
|
useRpgSessionPersistence({
|
||||||
|
authenticatedUserId: 'user-1',
|
||||||
|
gameState,
|
||||||
|
bottomTab: 'adventure' as BottomTab,
|
||||||
|
currentStory: { streaming: false } as StoryMoment,
|
||||||
|
isLoading: false,
|
||||||
|
setGameState: () => {},
|
||||||
|
setBottomTab: () => {},
|
||||||
|
hydrateStoryState: () => {},
|
||||||
|
resetStoryState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<AutosaveHarness />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(400);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(storageMocks.putSaveSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storageMocks.putSaveSnapshot).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
gameState: expect.objectContaining({
|
||||||
|
runtimeStats: expect.objectContaining({
|
||||||
|
playTimeMs: 2400,
|
||||||
|
lastPlayTickAt: '2026-04-27T10:00:02.400Z',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -46,7 +46,24 @@ export async function deleteBigFishWork(sessionId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录已发布大鱼吃小鱼作品的一次正式进入。
|
||||||
|
*/
|
||||||
|
export async function recordBigFishWorkPlay(sessionId: string) {
|
||||||
|
return requestJson<BigFishWorksResponse>(
|
||||||
|
`${BIG_FISH_WORKS_API_BASE}/${encodeURIComponent(sessionId)}/play`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
},
|
||||||
|
'记录大鱼吃小鱼游玩次数失败',
|
||||||
|
{
|
||||||
|
retry: BIG_FISH_WORKS_WRITE_RETRY,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const bigFishWorksClient = {
|
export const bigFishWorksClient = {
|
||||||
delete: deleteBigFishWork,
|
delete: deleteBigFishWork,
|
||||||
list: listBigFishWorks,
|
list: listBigFishWorks,
|
||||||
|
recordPlay: recordBigFishWorkPlay,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export {
|
|||||||
bigFishWorksClient,
|
bigFishWorksClient,
|
||||||
deleteBigFishWork,
|
deleteBigFishWork,
|
||||||
listBigFishWorks,
|
listBigFishWorks,
|
||||||
|
recordBigFishWorkPlay,
|
||||||
} from './bigFishWorksClient';
|
} from './bigFishWorksClient';
|
||||||
|
|||||||
Reference in New Issue
Block a user