1
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"provider": "ark",
|
||||
"protocol": "responses",
|
||||
"model": "deepseek-v3-2-251201",
|
||||
"stream": false,
|
||||
"attempt": 1,
|
||||
"maxTokens": null,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n这一步只保留世界顶层信息与一个开局归处占位,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物、地图细节或多幕场景内容。\n玩家设定:\n世界承诺:{\"hook\":\"在失真的海图上追查一场被篡改的沉船事故。\"}\n玩家切入口:{\"entryMotivation\":\"查清父亲沉船真相\",\"openingIdentity\":\"被停职返乡的守灯人\",\"openingProblem\":\"灯塔记录被人改写\"}\n\n输出 JSON 模板:\n{\n \"name\": \"世界名称\",\n \"subtitle\": \"世界副标题\",\n \"summary\": \"世界概述\",\n \"tone\": \"世界基调\",\n \"playerGoal\": \"玩家核心目标\",\n \"templateWorldType\": \"WUXIA|XIANXIA\",\n \"majorFactions\": [\"势力甲\", \"势力乙\"],\n \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],\n \"attributeSchema\": {\n \"slots\": [\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" }\n ]\n },\n \"camp\": {\n \"name\": \"开局归处名称\",\n \"description\": \"这是玩家进入世界后的第一处落脚点描述\"\n }\n}\n\n要求:\n- 所有生成文本都必须使用中文。\n- 这一步只输出顶层 10 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。\n- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。\n- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。\n- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。\n- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。\n- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。\n- attributeSchema 必须是本世界专属的角色六维名称体系,slots 必须恰好 6 个,每个 slot 只输出 name,维度名必须是 2 到 4 个汉字且互不重复。\n- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。\n- 不要在 attributeSchema.slots 内输出 definition、positiveSignals、negativeSignals、combatUseText、socialUseText、explorationUseText 或其他说明字段。\n- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。\n- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"choices":[{"message":{"content":"{\"name\":\"雾港归航\",\"subtitle\":\"失灯旧案\",\"summary\":\"守灯人与群岛议会围绕沉船旧案对峙。\",\"tone\":\"海雾悬疑\",\"playerGoal\":\"查清父亲沉船真相\",\"templateWorldType\":\"WUXIA\",\"majorFactions\":[\"群岛议会\",\"灯塔署\"],\"coreConflicts\":[\"守灯塔的旧档案被人改写。\"],\"attributeSchema\":{\"slots\":[{\"name\":\"灯骨\"},{\"name\":\"潮步\"},{\"name\":\"灯识\"},{\"name\":\"雾魄\"},{\"name\":\"旧约\"},{\"name\":\"回澜\"}]},\"camp\":{\"name\":\"旧灯塔归舍\",\"description\":\"海雾边缘的守灯人旧居。\"}}"}}],"id":"resp_01"}
|
||||
@@ -83,13 +83,13 @@ use crate::{
|
||||
phone_auth::{phone_login, send_phone_code},
|
||||
profile_identity::update_profile_identity,
|
||||
puzzle::{
|
||||
advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session,
|
||||
delete_puzzle_work, execute_puzzle_agent_action, get_puzzle_agent_session,
|
||||
get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works,
|
||||
list_puzzle_gallery, put_puzzle_work, record_puzzle_gallery_like,
|
||||
remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message,
|
||||
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
|
||||
update_puzzle_run_pause, use_puzzle_runtime_prop,
|
||||
advance_local_puzzle_next_level, advance_puzzle_next_level,
|
||||
claim_puzzle_work_point_incentive, create_puzzle_agent_session, delete_puzzle_work,
|
||||
execute_puzzle_agent_action, get_puzzle_agent_session, get_puzzle_gallery_detail,
|
||||
get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery,
|
||||
put_puzzle_work, record_puzzle_gallery_like, remix_puzzle_gallery_work, start_puzzle_run,
|
||||
stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard,
|
||||
swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop,
|
||||
},
|
||||
refresh_session::refresh_session,
|
||||
request_context::{attach_request_context, resolve_request_id},
|
||||
@@ -764,6 +764,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/puzzle/works/{profile_id}/point-incentive/claim",
|
||||
post(claim_puzzle_work_point_incentive).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route("/api/runtime/puzzle/gallery", get(list_puzzle_gallery))
|
||||
.route(
|
||||
"/api/runtime/puzzle/gallery/{profile_id}",
|
||||
|
||||
@@ -17,7 +17,10 @@ use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
|
||||
use module_puzzle::{
|
||||
PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus,
|
||||
PuzzleWorkProfile, resolve_puzzle_level_config,
|
||||
};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
@@ -61,8 +64,9 @@ use spacetime_client::{
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkProfileRecord,
|
||||
PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput,
|
||||
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
|
||||
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use tokio::time::sleep;
|
||||
@@ -966,6 +970,43 @@ pub async fn delete_puzzle_work(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn claim_puzzle_work_point_incentive(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(
|
||||
&request_context,
|
||||
PUZZLE_WORKS_PROVIDER,
|
||||
&profile_id,
|
||||
"profileId",
|
||||
)?;
|
||||
|
||||
let item = state
|
||||
.spacetime_client()
|
||||
.claim_puzzle_work_point_incentive(PuzzleWorkPointIncentiveClaimRecordInput {
|
||||
profile_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
claimed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_WORKS_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleWorkMutationResponse {
|
||||
item: map_puzzle_work_profile_response(&state, item),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_puzzle_gallery(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1370,6 +1411,7 @@ pub async fn use_puzzle_runtime_prop(
|
||||
owner_user_id: reducer_owner_user_id,
|
||||
prop_kind,
|
||||
used_at_micros: current_utc_micros(),
|
||||
spent_points: crate::asset_billing::ASSET_OPERATION_POINTS_COST,
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
@@ -1689,6 +1731,13 @@ fn map_puzzle_work_summary_response(
|
||||
remix_count: item.remix_count,
|
||||
like_count: item.like_count,
|
||||
recent_play_count_7d: item.recent_play_count_7d,
|
||||
point_incentive_total_half_points: item.point_incentive_total_half_points,
|
||||
point_incentive_claimed_points: item.point_incentive_claimed_points,
|
||||
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
|
||||
point_incentive_claimable_points: item
|
||||
.point_incentive_total_half_points
|
||||
.saturating_div(2)
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
levels: Vec::new(),
|
||||
}
|
||||
@@ -1898,7 +1947,8 @@ fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> Puzzle
|
||||
fn map_puzzle_runtime_level_response(
|
||||
level: spacetime_client::PuzzleRuntimeLevelRecord,
|
||||
) -> PuzzleRuntimeLevelSnapshotResponse {
|
||||
let timer_defaults = build_puzzle_runtime_timer_response_defaults(level.grid_size);
|
||||
let timer_defaults =
|
||||
build_puzzle_runtime_timer_response_defaults(level.level_index, level.grid_size);
|
||||
let time_limit_ms = if level.time_limit_ms == 0 {
|
||||
timer_defaults.time_limit_ms
|
||||
} else {
|
||||
@@ -1945,9 +1995,14 @@ struct PuzzleRuntimeTimerResponseDefaults {
|
||||
}
|
||||
|
||||
fn build_puzzle_runtime_timer_response_defaults(
|
||||
level_index: u32,
|
||||
grid_size: u32,
|
||||
) -> PuzzleRuntimeTimerResponseDefaults {
|
||||
let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size);
|
||||
let time_limit_ms = if level_index > 0 {
|
||||
module_puzzle::resolve_puzzle_level_time_limit_ms_by_index(level_index)
|
||||
} else {
|
||||
module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size)
|
||||
};
|
||||
PuzzleRuntimeTimerResponseDefaults { time_limit_ms }
|
||||
}
|
||||
|
||||
@@ -2697,8 +2752,11 @@ async fn build_local_next_puzzle_run(
|
||||
return Ok(next_run);
|
||||
}
|
||||
|
||||
if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? {
|
||||
return Ok(build_next_run_from_puzzle_work(state, run, gallery_item));
|
||||
let current_work = fetch_local_current_work_detail(state, &run).await?;
|
||||
let similar_works =
|
||||
resolve_gallery_similar_puzzle_works(state, &run, current_work.as_ref()).await?;
|
||||
if !similar_works.is_empty() {
|
||||
return Ok(build_local_similar_works_handoff(run, similar_works));
|
||||
}
|
||||
|
||||
if source_session_id.trim().is_empty() {
|
||||
@@ -2886,23 +2944,187 @@ async fn fetch_local_current_work_detail(
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_gallery_next_puzzle_work(
|
||||
async fn resolve_gallery_similar_puzzle_works(
|
||||
state: &AppState,
|
||||
run: &PuzzleRunRecord,
|
||||
) -> Result<Option<PuzzleWorkProfileRecord>, AppError> {
|
||||
current_work: Option<&PuzzleWorkProfileRecord>,
|
||||
) -> Result<Vec<PuzzleRecommendedNextWorkRecord>, AppError> {
|
||||
let Some(current_profile) = build_recommendation_current_profile(run, current_work) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let items = state
|
||||
.spacetime_client()
|
||||
.list_puzzle_gallery()
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
Ok(items.into_iter().find(|item| {
|
||||
item.publication_status == "published"
|
||||
&& item
|
||||
.cover_image_src
|
||||
.as_ref()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
&& !run.played_profile_ids.contains(&item.profile_id)
|
||||
}))
|
||||
let candidates = items
|
||||
.iter()
|
||||
.map(map_puzzle_work_profile_domain)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(module_puzzle::select_next_profiles(
|
||||
¤t_profile,
|
||||
&run.played_profile_ids,
|
||||
&candidates,
|
||||
3,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|candidate| build_recommended_next_work_record(¤t_profile, candidate))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn build_local_similar_works_handoff(
|
||||
mut run: PuzzleRunRecord,
|
||||
recommended_next_works: Vec<PuzzleRecommendedNextWorkRecord>,
|
||||
) -> PuzzleRunRecord {
|
||||
let next_profile_id = recommended_next_works
|
||||
.first()
|
||||
.map(|item| item.profile_id.clone());
|
||||
run.recommended_next_profile_id = next_profile_id.clone();
|
||||
run.next_level_mode = module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS.to_string();
|
||||
run.next_level_profile_id = next_profile_id;
|
||||
run.next_level_id = None;
|
||||
run.recommended_next_works = recommended_next_works;
|
||||
run
|
||||
}
|
||||
|
||||
fn build_recommendation_current_profile(
|
||||
run: &PuzzleRunRecord,
|
||||
current_work: Option<&PuzzleWorkProfileRecord>,
|
||||
) -> Option<PuzzleWorkProfile> {
|
||||
if let Some(work) = current_work {
|
||||
return Some(map_puzzle_work_profile_domain(work));
|
||||
}
|
||||
|
||||
let level = run.current_level.as_ref()?;
|
||||
Some(PuzzleWorkProfile {
|
||||
work_id: format!("runtime-work-{}", level.profile_id),
|
||||
profile_id: level.profile_id.clone(),
|
||||
owner_user_id: String::new(),
|
||||
source_session_id: None,
|
||||
author_display_name: level.author_display_name.clone(),
|
||||
work_title: level.level_name.clone(),
|
||||
work_description: String::new(),
|
||||
level_name: level.level_name.clone(),
|
||||
summary: String::new(),
|
||||
theme_tags: level.theme_tags.clone(),
|
||||
cover_image_src: level.cover_image_src.clone(),
|
||||
cover_asset_id: None,
|
||||
levels: Vec::new(),
|
||||
publication_status: module_puzzle::PuzzlePublicationStatus::Published,
|
||||
updated_at_micros: 0,
|
||||
published_at_micros: None,
|
||||
play_count: 0,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
recent_play_count_7d: 0,
|
||||
point_incentive_total_half_points: 0,
|
||||
point_incentive_claimed_points: 0,
|
||||
publish_ready: true,
|
||||
anchor_pack: module_puzzle::empty_anchor_pack(),
|
||||
})
|
||||
}
|
||||
|
||||
fn map_puzzle_work_profile_domain(item: &PuzzleWorkProfileRecord) -> PuzzleWorkProfile {
|
||||
PuzzleWorkProfile {
|
||||
work_id: item.work_id.clone(),
|
||||
profile_id: item.profile_id.clone(),
|
||||
owner_user_id: item.owner_user_id.clone(),
|
||||
source_session_id: item.source_session_id.clone(),
|
||||
author_display_name: item.author_display_name.clone(),
|
||||
work_title: item.work_title.clone(),
|
||||
work_description: item.work_description.clone(),
|
||||
level_name: item.level_name.clone(),
|
||||
summary: item.summary.clone(),
|
||||
theme_tags: item.theme_tags.clone(),
|
||||
cover_image_src: item.cover_image_src.clone(),
|
||||
cover_asset_id: item.cover_asset_id.clone(),
|
||||
levels: item
|
||||
.levels
|
||||
.iter()
|
||||
.map(map_puzzle_draft_level_domain)
|
||||
.collect(),
|
||||
publication_status: match item.publication_status.as_str() {
|
||||
"published" => module_puzzle::PuzzlePublicationStatus::Published,
|
||||
_ => module_puzzle::PuzzlePublicationStatus::Draft,
|
||||
},
|
||||
updated_at_micros: parse_puzzle_record_timestamp_micros(&item.updated_at),
|
||||
published_at_micros: item
|
||||
.published_at
|
||||
.as_deref()
|
||||
.map(parse_puzzle_record_timestamp_micros),
|
||||
play_count: item.play_count,
|
||||
remix_count: item.remix_count,
|
||||
like_count: item.like_count,
|
||||
recent_play_count_7d: item.recent_play_count_7d,
|
||||
point_incentive_total_half_points: item.point_incentive_total_half_points,
|
||||
point_incentive_claimed_points: item.point_incentive_claimed_points,
|
||||
publish_ready: item.publish_ready,
|
||||
anchor_pack: module_puzzle::empty_anchor_pack(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_draft_level_domain(
|
||||
level: &PuzzleDraftLevelRecord,
|
||||
) -> module_puzzle::PuzzleDraftLevel {
|
||||
module_puzzle::PuzzleDraftLevel {
|
||||
level_id: level.level_id.clone(),
|
||||
level_name: level.level_name.clone(),
|
||||
picture_description: level.picture_description.clone(),
|
||||
candidates: level
|
||||
.candidates
|
||||
.iter()
|
||||
.map(map_puzzle_generated_image_candidate_domain)
|
||||
.collect(),
|
||||
selected_candidate_id: level.selected_candidate_id.clone(),
|
||||
cover_image_src: level.cover_image_src.clone(),
|
||||
cover_asset_id: level.cover_asset_id.clone(),
|
||||
generation_status: level.generation_status.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_generated_image_candidate_domain(
|
||||
candidate: &PuzzleGeneratedImageCandidateRecord,
|
||||
) -> PuzzleGeneratedImageCandidate {
|
||||
PuzzleGeneratedImageCandidate {
|
||||
candidate_id: candidate.candidate_id.clone(),
|
||||
image_src: candidate.image_src.clone(),
|
||||
asset_id: candidate.asset_id.clone(),
|
||||
prompt: candidate.prompt.clone(),
|
||||
actual_prompt: candidate.actual_prompt.clone(),
|
||||
source_type: candidate.source_type.clone(),
|
||||
selected: candidate.selected,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_recommended_next_work_record(
|
||||
current_profile: &PuzzleWorkProfile,
|
||||
candidate: &PuzzleWorkProfile,
|
||||
) -> PuzzleRecommendedNextWorkRecord {
|
||||
PuzzleRecommendedNextWorkRecord {
|
||||
profile_id: candidate.profile_id.clone(),
|
||||
level_name: candidate.level_name.clone(),
|
||||
author_display_name: candidate.author_display_name.clone(),
|
||||
theme_tags: candidate.theme_tags.clone(),
|
||||
cover_image_src: candidate.cover_image_src.clone(),
|
||||
similarity_score: module_puzzle::tag_similarity_score(
|
||||
¤t_profile.theme_tags,
|
||||
&candidate.theme_tags,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_puzzle_record_timestamp_micros(value: &str) -> i64 {
|
||||
let Some((seconds, rest)) = value.split_once('.') else {
|
||||
return 0;
|
||||
};
|
||||
let micros = rest.strip_suffix('Z').unwrap_or(rest);
|
||||
let Ok(seconds) = seconds.parse::<i64>() else {
|
||||
return 0;
|
||||
};
|
||||
let Ok(micros) = micros.parse::<i64>() else {
|
||||
return 0;
|
||||
};
|
||||
seconds.saturating_mul(1_000_000).saturating_add(micros)
|
||||
}
|
||||
|
||||
fn pick_unused_puzzle_candidate<'a>(
|
||||
@@ -2987,27 +3209,6 @@ fn resolve_level_cover_image_src(level: &PuzzleDraftLevelRecord) -> Option<Strin
|
||||
})
|
||||
}
|
||||
|
||||
fn build_next_run_from_puzzle_work(
|
||||
state: &AppState,
|
||||
run: PuzzleRunRecord,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleRunRecord {
|
||||
let author = resolve_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
Some(&item.author_display_name),
|
||||
None,
|
||||
);
|
||||
build_next_run_from_parts(
|
||||
run,
|
||||
item.profile_id,
|
||||
item.level_name,
|
||||
author.display_name,
|
||||
item.theme_tags,
|
||||
item.cover_image_src,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_next_run_from_candidate(
|
||||
run: PuzzleRunRecord,
|
||||
session: &PuzzleAgentSessionRecord,
|
||||
@@ -3089,8 +3290,9 @@ fn build_next_run_from_parts_with_handoff(
|
||||
next_after_level_id: Option<String>,
|
||||
) -> PuzzleRunRecord {
|
||||
let next_level_index = run.current_level_index + 1;
|
||||
let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 };
|
||||
let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size);
|
||||
let level_config = resolve_puzzle_level_config(next_level_index);
|
||||
let grid_size = level_config.grid_size;
|
||||
let time_limit_ms = level_config.time_limit_ms;
|
||||
let mut played_profile_ids = run.played_profile_ids.clone();
|
||||
let current_level_id = run.next_level_id.clone();
|
||||
if !played_profile_ids.contains(&profile_id) {
|
||||
@@ -3250,6 +3452,98 @@ mod tests {
|
||||
assert!(!has_original_neighbor_pair(&third));
|
||||
}
|
||||
|
||||
fn test_recommended_work(profile_id: &str, score: f32) -> PuzzleRecommendedNextWorkRecord {
|
||||
PuzzleRecommendedNextWorkRecord {
|
||||
profile_id: profile_id.to_string(),
|
||||
level_name: format!("{profile_id} 关"),
|
||||
author_display_name: "作者".to_string(),
|
||||
theme_tags: vec!["奇幻".to_string()],
|
||||
cover_image_src: Some(format!("/{profile_id}.png")),
|
||||
similarity_score: score,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_similar_works_handoff_keeps_cleared_run_for_user_choice() {
|
||||
let run = PuzzleRunRecord {
|
||||
run_id: "local-puzzle-run-a".to_string(),
|
||||
entry_profile_id: "profile-current".to_string(),
|
||||
cleared_level_count: 1,
|
||||
current_level_index: 1,
|
||||
current_grid_size: 3,
|
||||
played_profile_ids: vec!["profile-current".to_string()],
|
||||
previous_level_tags: vec!["奇幻".to_string()],
|
||||
current_level: Some(PuzzleRuntimeLevelRecord {
|
||||
run_id: "local-puzzle-run-a".to_string(),
|
||||
level_index: 1,
|
||||
level_id: Some("puzzle-level-1".to_string()),
|
||||
grid_size: 3,
|
||||
profile_id: "profile-current".to_string(),
|
||||
level_name: "当前拼图".to_string(),
|
||||
author_display_name: "当前作者".to_string(),
|
||||
theme_tags: vec!["奇幻".to_string()],
|
||||
cover_image_src: Some("/current.png".to_string()),
|
||||
board: build_local_puzzle_board(3, "local-puzzle-run-a", "profile-current", 1),
|
||||
status: "cleared".to_string(),
|
||||
started_at_ms: 1_000,
|
||||
cleared_at_ms: Some(2_000),
|
||||
elapsed_ms: Some(1_000),
|
||||
time_limit_ms: 300_000,
|
||||
remaining_ms: 0,
|
||||
paused_accumulated_ms: 0,
|
||||
pause_started_at_ms: None,
|
||||
freeze_accumulated_ms: 0,
|
||||
freeze_started_at_ms: None,
|
||||
freeze_until_ms: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
}),
|
||||
recommended_next_profile_id: None,
|
||||
next_level_mode: module_puzzle::PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(),
|
||||
next_level_profile_id: None,
|
||||
next_level_id: None,
|
||||
recommended_next_works: Vec::new(),
|
||||
leaderboard_entries: Vec::new(),
|
||||
};
|
||||
|
||||
let next_run = build_local_similar_works_handoff(
|
||||
run,
|
||||
vec![
|
||||
test_recommended_work("profile-a", 0.9),
|
||||
test_recommended_work("profile-b", 0.8),
|
||||
test_recommended_work("profile-c", 0.7),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
next_run.next_level_mode,
|
||||
module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS
|
||||
);
|
||||
assert_eq!(
|
||||
next_run.recommended_next_profile_id.as_deref(),
|
||||
Some("profile-a")
|
||||
);
|
||||
assert_eq!(next_run.next_level_profile_id.as_deref(), Some("profile-a"));
|
||||
assert_eq!(next_run.next_level_id, None);
|
||||
assert_eq!(next_run.recommended_next_works.len(), 3);
|
||||
assert_eq!(next_run.current_level_index, 1);
|
||||
assert_eq!(
|
||||
next_run
|
||||
.current_level
|
||||
.as_ref()
|
||||
.map(|level| level.status.as_str()),
|
||||
Some("cleared")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_record_timestamp_parser_matches_shared_format() {
|
||||
assert_eq!(
|
||||
parse_puzzle_record_timestamp_micros("1713686401.234567Z"),
|
||||
1_713_686_401_234_567
|
||||
);
|
||||
assert_eq!(parse_puzzle_record_timestamp_micros("bad-value"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_generated_image_size_is_square_1_1() {
|
||||
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024");
|
||||
|
||||
@@ -21,6 +21,7 @@ use shared_contracts::runtime::{
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
|
||||
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
|
||||
@@ -127,6 +128,9 @@ fn format_profile_wallet_ledger_source_type(
|
||||
RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD
|
||||
}
|
||||
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,7 +566,7 @@ mod tests {
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[test]
|
||||
fn profile_wallet_ledger_source_type_formats_asset_operation_values() {
|
||||
fn profile_wallet_ledger_source_type_formats_backend_values() {
|
||||
assert_eq!(
|
||||
format_profile_wallet_ledger_source_type(
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
|
||||
@@ -575,6 +579,12 @@ mod tests {
|
||||
),
|
||||
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
|
||||
);
|
||||
assert_eq!(
|
||||
format_profile_wallet_ledger_source_type(
|
||||
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim
|
||||
),
|
||||
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user