1
This commit is contained in:
@@ -15,6 +15,7 @@ pub const PUZZLE_PROFILE_ID_PREFIX: &str = "puzzle-profile-";
|
||||
pub const PUZZLE_RUN_ID_PREFIX: &str = "puzzle-run-";
|
||||
pub const PUZZLE_MIN_TAG_COUNT: usize = 3;
|
||||
pub const PUZZLE_MAX_TAG_COUNT: usize = 6;
|
||||
pub const PUZZLE_FREEZE_TIME_DURATION_MS: u64 = 10_000;
|
||||
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -65,6 +66,7 @@ pub enum PuzzlePublicationStatus {
|
||||
pub enum PuzzleRuntimeLevelStatus {
|
||||
Playing,
|
||||
Cleared,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -214,6 +216,8 @@ pub struct PuzzleWorkProfile {
|
||||
pub remix_count: u32,
|
||||
#[serde(default)]
|
||||
pub like_count: u32,
|
||||
#[serde(default)]
|
||||
pub recent_play_count_7d: u32,
|
||||
pub publish_ready: bool,
|
||||
pub anchor_pack: PuzzleAnchorPack,
|
||||
}
|
||||
@@ -260,7 +264,9 @@ pub struct PuzzleBoardSnapshot {
|
||||
pub cols: u32,
|
||||
pub pieces: Vec<PuzzlePieceState>,
|
||||
pub merged_groups: Vec<PuzzleMergedGroupState>,
|
||||
#[serde(default)]
|
||||
pub selected_piece_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub all_tiles_resolved: bool,
|
||||
}
|
||||
|
||||
@@ -277,9 +283,27 @@ pub struct PuzzleRuntimeLevelSnapshot {
|
||||
pub cover_image_src: Option<String>,
|
||||
pub board: PuzzleBoardSnapshot,
|
||||
pub status: PuzzleRuntimeLevelStatus,
|
||||
#[serde(default)]
|
||||
pub started_at_ms: u64,
|
||||
#[serde(default)]
|
||||
pub cleared_at_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub elapsed_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub time_limit_ms: u64,
|
||||
#[serde(default)]
|
||||
pub remaining_ms: u64,
|
||||
#[serde(default)]
|
||||
pub paused_accumulated_ms: u64,
|
||||
#[serde(default)]
|
||||
pub pause_started_at_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub freeze_accumulated_ms: u64,
|
||||
#[serde(default)]
|
||||
pub freeze_started_at_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub freeze_until_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
|
||||
}
|
||||
|
||||
@@ -295,6 +319,7 @@ pub struct PuzzleRunSnapshot {
|
||||
pub previous_level_tags: Vec<String>,
|
||||
pub current_level: Option<PuzzleRuntimeLevelSnapshot>,
|
||||
pub recommended_next_profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
|
||||
}
|
||||
|
||||
@@ -470,6 +495,24 @@ pub struct PuzzleRunNextLevelInput {
|
||||
pub advanced_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleRunPauseInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub paused: bool,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleRunPropInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub prop_kind: String,
|
||||
pub used_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleLeaderboardSubmitInput {
|
||||
@@ -605,6 +648,7 @@ impl PuzzleRuntimeLevelStatus {
|
||||
match self {
|
||||
Self::Playing => "playing",
|
||||
Self::Cleared => "cleared",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -648,6 +692,10 @@ pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> Puzzl
|
||||
let source = normalize_required_string(latest_message.unwrap_or(seed_text))
|
||||
.or_else(|| normalize_required_string(seed_text))
|
||||
.unwrap_or_else(|| "童话森林里的发光猫咪遗迹".to_string());
|
||||
if let Some((title, picture_description)) = parse_form_seed_text(&source) {
|
||||
return build_form_anchor_pack(title.as_str(), picture_description.as_str());
|
||||
}
|
||||
|
||||
let mut pack = empty_anchor_pack();
|
||||
pack.theme_promise.value = infer_theme_promise(&source);
|
||||
pack.theme_promise.status = PuzzleAnchorStatus::Inferred;
|
||||
@@ -662,12 +710,38 @@ pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> Puzzl
|
||||
pack
|
||||
}
|
||||
|
||||
pub fn build_form_anchor_pack(title: &str, picture_description: &str) -> PuzzleAnchorPack {
|
||||
let normalized_title =
|
||||
normalize_required_string(title).unwrap_or_else(|| "奇景拼图".to_string());
|
||||
let normalized_description =
|
||||
normalize_required_string(picture_description).unwrap_or_else(|| normalized_title.clone());
|
||||
let mut pack = empty_anchor_pack();
|
||||
|
||||
pack.theme_promise.value = normalized_title.clone();
|
||||
pack.theme_promise.status = PuzzleAnchorStatus::Locked;
|
||||
pack.visual_subject.value = normalized_description.clone();
|
||||
pack.visual_subject.status = PuzzleAnchorStatus::Locked;
|
||||
pack.visual_mood.value = "清晰、适合拼图切块".to_string();
|
||||
pack.visual_mood.status = PuzzleAnchorStatus::Inferred;
|
||||
pack.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string();
|
||||
pack.composition_hooks.status = PuzzleAnchorStatus::Inferred;
|
||||
pack.tags_and_forbidden.value =
|
||||
build_form_tags_and_forbidden(normalized_title.as_str(), normalized_description.as_str());
|
||||
pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred;
|
||||
|
||||
pack
|
||||
}
|
||||
|
||||
pub fn build_creator_intent(
|
||||
anchor_pack: &PuzzleAnchorPack,
|
||||
messages: &[PuzzleAgentMessageSnapshot],
|
||||
) -> PuzzleCreatorIntent {
|
||||
PuzzleCreatorIntent {
|
||||
source_mode: "agent_chat".to_string(),
|
||||
source_mode: if is_form_anchor_pack(anchor_pack) {
|
||||
"form".to_string()
|
||||
} else {
|
||||
"agent_chat".to_string()
|
||||
},
|
||||
raw_messages_summary: messages
|
||||
.iter()
|
||||
.rev()
|
||||
@@ -698,12 +772,7 @@ pub fn compile_result_draft(
|
||||
let level_name = build_level_name(anchor_pack, &normalized_tags);
|
||||
PuzzleResultDraft {
|
||||
level_name,
|
||||
summary: format!(
|
||||
"{},主体是{},氛围偏{}。",
|
||||
fallback_text(&anchor_pack.theme_promise.value, "梦幻题材"),
|
||||
fallback_text(&anchor_pack.visual_subject.value, "画面主体"),
|
||||
fallback_text(&anchor_pack.visual_mood.value, "温暖")
|
||||
),
|
||||
summary: build_result_summary(anchor_pack),
|
||||
theme_tags: normalized_tags,
|
||||
forbidden_directives: creator_intent.forbidden_directives.clone(),
|
||||
creator_intent: Some(creator_intent),
|
||||
@@ -866,6 +935,7 @@ pub fn create_work_profile(
|
||||
play_count: 0,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
recent_play_count_7d: 0,
|
||||
publish_ready: preview.publish_ready,
|
||||
anchor_pack: draft.anchor_pack.clone(),
|
||||
})
|
||||
@@ -930,6 +1000,159 @@ pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 {
|
||||
if cleared_level_count >= 3 { 4 } else { 3 }
|
||||
}
|
||||
|
||||
pub fn resolve_puzzle_level_time_limit_ms(grid_size: u32) -> u64 {
|
||||
match grid_size {
|
||||
4 => 300_000,
|
||||
_ => 180_000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 {
|
||||
let time_limit_ms = if level.time_limit_ms == 0 {
|
||||
resolve_puzzle_level_time_limit_ms(level.grid_size)
|
||||
} else {
|
||||
level.time_limit_ms
|
||||
};
|
||||
time_limit_ms.saturating_sub(resolve_effective_elapsed_ms(level, now_ms))
|
||||
}
|
||||
|
||||
fn normalize_timer_fields(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) {
|
||||
if level.started_at_ms == 0 {
|
||||
level.started_at_ms = now_ms;
|
||||
}
|
||||
if level.time_limit_ms == 0 {
|
||||
level.time_limit_ms = resolve_puzzle_level_time_limit_ms(level.grid_size);
|
||||
}
|
||||
if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing {
|
||||
level.remaining_ms = level.time_limit_ms;
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_active_freeze_elapsed_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 {
|
||||
match (level.freeze_started_at_ms, level.freeze_until_ms) {
|
||||
(Some(started_at), Some(until_ms)) => now_ms.min(until_ms).saturating_sub(started_at),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_effective_elapsed_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 {
|
||||
let pause_elapsed_ms = level
|
||||
.pause_started_at_ms
|
||||
.map(|started_at| now_ms.saturating_sub(started_at))
|
||||
.unwrap_or(0);
|
||||
now_ms
|
||||
.saturating_sub(level.started_at_ms)
|
||||
.saturating_sub(level.paused_accumulated_ms)
|
||||
.saturating_sub(pause_elapsed_ms)
|
||||
.saturating_sub(level.freeze_accumulated_ms)
|
||||
.saturating_sub(resolve_active_freeze_elapsed_ms(level, now_ms))
|
||||
}
|
||||
|
||||
fn settle_expired_freeze(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) {
|
||||
let (Some(started_at), Some(until_ms)) = (level.freeze_started_at_ms, level.freeze_until_ms)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if now_ms < until_ms {
|
||||
return;
|
||||
}
|
||||
level.freeze_accumulated_ms = level
|
||||
.freeze_accumulated_ms
|
||||
.saturating_add(until_ms.saturating_sub(started_at));
|
||||
level.freeze_started_at_ms = None;
|
||||
level.freeze_until_ms = None;
|
||||
}
|
||||
|
||||
fn close_level_pause(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) {
|
||||
if let Some(pause_started_at_ms) = level.pause_started_at_ms.take() {
|
||||
level.paused_accumulated_ms = level
|
||||
.paused_accumulated_ms
|
||||
.saturating_add(now_ms.saturating_sub(pause_started_at_ms));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_puzzle_run_timer_at(mut run: PuzzleRunSnapshot, now_ms: u64) -> PuzzleRunSnapshot {
|
||||
let Some(current_level) = run.current_level.as_mut() else {
|
||||
return run;
|
||||
};
|
||||
normalize_timer_fields(current_level, now_ms);
|
||||
if current_level.status != PuzzleRuntimeLevelStatus::Playing {
|
||||
return run;
|
||||
}
|
||||
|
||||
settle_expired_freeze(current_level, now_ms);
|
||||
let effective_elapsed_ms = resolve_effective_elapsed_ms(current_level, now_ms);
|
||||
current_level.remaining_ms = current_level
|
||||
.time_limit_ms
|
||||
.saturating_sub(effective_elapsed_ms);
|
||||
if current_level.remaining_ms == 0 {
|
||||
current_level.status = PuzzleRuntimeLevelStatus::Failed;
|
||||
current_level.elapsed_ms = Some(current_level.time_limit_ms);
|
||||
current_level.pause_started_at_ms = None;
|
||||
current_level.freeze_started_at_ms = None;
|
||||
current_level.freeze_until_ms = None;
|
||||
}
|
||||
run
|
||||
}
|
||||
|
||||
pub fn resolve_puzzle_run_timer(run: PuzzleRunSnapshot) -> PuzzleRunSnapshot {
|
||||
resolve_puzzle_run_timer_at(run, current_unix_ms())
|
||||
}
|
||||
|
||||
pub fn set_puzzle_run_paused_at(
|
||||
run: &PuzzleRunSnapshot,
|
||||
paused: bool,
|
||||
now_ms: u64,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms);
|
||||
let current_level = next_run
|
||||
.current_level
|
||||
.as_mut()
|
||||
.ok_or(PuzzleFieldError::InvalidOperation)?;
|
||||
if current_level.status != PuzzleRuntimeLevelStatus::Playing {
|
||||
return Ok(next_run);
|
||||
}
|
||||
if paused {
|
||||
if current_level.pause_started_at_ms.is_none() {
|
||||
current_level.pause_started_at_ms = Some(now_ms);
|
||||
}
|
||||
return Ok(next_run);
|
||||
}
|
||||
close_level_pause(current_level, now_ms);
|
||||
Ok(resolve_puzzle_run_timer_at(next_run, now_ms))
|
||||
}
|
||||
|
||||
pub fn set_puzzle_run_paused(
|
||||
run: &PuzzleRunSnapshot,
|
||||
paused: bool,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
set_puzzle_run_paused_at(run, paused, current_unix_ms())
|
||||
}
|
||||
|
||||
pub fn apply_puzzle_freeze_time_at(
|
||||
run: &PuzzleRunSnapshot,
|
||||
now_ms: u64,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms);
|
||||
let current_level = next_run
|
||||
.current_level
|
||||
.as_mut()
|
||||
.ok_or(PuzzleFieldError::InvalidOperation)?;
|
||||
if current_level.status != PuzzleRuntimeLevelStatus::Playing {
|
||||
return Err(PuzzleFieldError::InvalidOperation);
|
||||
}
|
||||
close_level_pause(current_level, now_ms);
|
||||
current_level.freeze_started_at_ms = Some(now_ms);
|
||||
current_level.freeze_until_ms = Some(now_ms.saturating_add(PUZZLE_FREEZE_TIME_DURATION_MS));
|
||||
Ok(next_run)
|
||||
}
|
||||
|
||||
pub fn apply_puzzle_freeze_time(
|
||||
run: &PuzzleRunSnapshot,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
apply_puzzle_freeze_time_at(run, current_unix_ms())
|
||||
}
|
||||
|
||||
pub fn build_initial_board(grid_size: u32) -> Result<PuzzleBoardSnapshot, PuzzleFieldError> {
|
||||
build_initial_board_with_seed(grid_size, 0)
|
||||
}
|
||||
@@ -951,6 +1174,20 @@ pub fn start_run(
|
||||
run_id: String,
|
||||
entry_profile: &PuzzleWorkProfile,
|
||||
cleared_level_count: u32,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
start_run_at(
|
||||
run_id,
|
||||
entry_profile,
|
||||
cleared_level_count,
|
||||
current_unix_ms(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn start_run_at(
|
||||
run_id: String,
|
||||
entry_profile: &PuzzleWorkProfile,
|
||||
cleared_level_count: u32,
|
||||
started_at_ms: u64,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
|
||||
let shuffle_seed = puzzle_shuffle_seed(
|
||||
@@ -959,7 +1196,13 @@ pub fn start_run(
|
||||
cleared_level_count + 1,
|
||||
grid_size,
|
||||
);
|
||||
start_run_with_shuffle_seed(run_id, entry_profile, cleared_level_count, shuffle_seed)
|
||||
start_run_with_shuffle_seed_at(
|
||||
run_id,
|
||||
entry_profile,
|
||||
cleared_level_count,
|
||||
shuffle_seed,
|
||||
started_at_ms,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn start_run_with_shuffle_seed(
|
||||
@@ -967,10 +1210,25 @@ pub fn start_run_with_shuffle_seed(
|
||||
entry_profile: &PuzzleWorkProfile,
|
||||
cleared_level_count: u32,
|
||||
shuffle_seed: u64,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
start_run_with_shuffle_seed_at(
|
||||
run_id,
|
||||
entry_profile,
|
||||
cleared_level_count,
|
||||
shuffle_seed,
|
||||
current_unix_ms(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn start_run_with_shuffle_seed_at(
|
||||
run_id: String,
|
||||
entry_profile: &PuzzleWorkProfile,
|
||||
cleared_level_count: u32,
|
||||
shuffle_seed: u64,
|
||||
started_at_ms: u64,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
|
||||
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
|
||||
let started_at_ms = current_unix_ms();
|
||||
Ok(PuzzleRunSnapshot {
|
||||
run_id: run_id.clone(),
|
||||
entry_profile_id: entry_profile.profile_id.clone(),
|
||||
@@ -993,6 +1251,13 @@ pub fn start_run_with_shuffle_seed(
|
||||
started_at_ms,
|
||||
cleared_at_ms: None,
|
||||
elapsed_ms: None,
|
||||
time_limit_ms: resolve_puzzle_level_time_limit_ms(grid_size),
|
||||
remaining_ms: resolve_puzzle_level_time_limit_ms(grid_size),
|
||||
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,
|
||||
@@ -1004,16 +1269,26 @@ pub fn swap_pieces(
|
||||
run: &PuzzleRunSnapshot,
|
||||
first_piece_id: &str,
|
||||
second_piece_id: &str,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
swap_pieces_at(run, first_piece_id, second_piece_id, current_unix_ms())
|
||||
}
|
||||
|
||||
pub fn swap_pieces_at(
|
||||
run: &PuzzleRunSnapshot,
|
||||
first_piece_id: &str,
|
||||
second_piece_id: &str,
|
||||
now_ms: u64,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let first_piece_id =
|
||||
normalize_required_string(first_piece_id).ok_or(PuzzleFieldError::MissingPieceId)?;
|
||||
let second_piece_id =
|
||||
normalize_required_string(second_piece_id).ok_or(PuzzleFieldError::MissingPieceId)?;
|
||||
let current_level = run
|
||||
let timed_run = resolve_puzzle_run_timer_at(run.clone(), now_ms);
|
||||
let current_level = timed_run
|
||||
.current_level
|
||||
.clone()
|
||||
.ok_or(PuzzleFieldError::InvalidOperation)?;
|
||||
if current_level.status == PuzzleRuntimeLevelStatus::Cleared {
|
||||
if current_level.status != PuzzleRuntimeLevelStatus::Playing {
|
||||
return Err(PuzzleFieldError::InvalidOperation);
|
||||
}
|
||||
let mut pieces = current_level.board.pieces.clone();
|
||||
@@ -1056,7 +1331,7 @@ pub fn swap_pieces(
|
||||
affected_cells,
|
||||
None,
|
||||
);
|
||||
Ok(with_next_board(run, next_board))
|
||||
Ok(with_next_board_at(&timed_run, next_board, now_ms))
|
||||
}
|
||||
|
||||
pub fn drag_piece_or_group(
|
||||
@@ -1064,13 +1339,24 @@ pub fn drag_piece_or_group(
|
||||
piece_id: &str,
|
||||
target_row: u32,
|
||||
target_col: u32,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
drag_piece_or_group_at(run, piece_id, target_row, target_col, current_unix_ms())
|
||||
}
|
||||
|
||||
pub fn drag_piece_or_group_at(
|
||||
run: &PuzzleRunSnapshot,
|
||||
piece_id: &str,
|
||||
target_row: u32,
|
||||
target_col: u32,
|
||||
now_ms: u64,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let piece_id = normalize_required_string(piece_id).ok_or(PuzzleFieldError::MissingPieceId)?;
|
||||
let current_level = run
|
||||
let timed_run = resolve_puzzle_run_timer_at(run.clone(), now_ms);
|
||||
let current_level = timed_run
|
||||
.current_level
|
||||
.clone()
|
||||
.ok_or(PuzzleFieldError::InvalidOperation)?;
|
||||
if current_level.status == PuzzleRuntimeLevelStatus::Cleared {
|
||||
if current_level.status != PuzzleRuntimeLevelStatus::Playing {
|
||||
return Err(PuzzleFieldError::InvalidOperation);
|
||||
}
|
||||
let grid_size = current_level.grid_size;
|
||||
@@ -1097,7 +1383,7 @@ pub fn drag_piece_or_group(
|
||||
operation_cells,
|
||||
None,
|
||||
);
|
||||
Ok(with_next_board(run, next_board))
|
||||
Ok(with_next_board_at(&timed_run, next_board, now_ms))
|
||||
}
|
||||
|
||||
pub fn rebuild_board_snapshot_for_affected_cells(
|
||||
@@ -1175,6 +1461,14 @@ pub fn rebuild_board_snapshot_for_affected_cells(
|
||||
pub fn advance_next_level(
|
||||
run: &PuzzleRunSnapshot,
|
||||
next_profile: &PuzzleWorkProfile,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
advance_next_level_at(run, next_profile, current_unix_ms())
|
||||
}
|
||||
|
||||
pub fn advance_next_level_at(
|
||||
run: &PuzzleRunSnapshot,
|
||||
next_profile: &PuzzleWorkProfile,
|
||||
started_at_ms: u64,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let current_level = run
|
||||
.current_level
|
||||
@@ -1215,9 +1509,16 @@ pub fn advance_next_level(
|
||||
cover_image_src: next_profile.cover_image_src.clone(),
|
||||
board: next_board,
|
||||
status: PuzzleRuntimeLevelStatus::Playing,
|
||||
started_at_ms: current_unix_ms(),
|
||||
started_at_ms,
|
||||
cleared_at_ms: None,
|
||||
elapsed_ms: None,
|
||||
time_limit_ms: resolve_puzzle_level_time_limit_ms(next_grid_size),
|
||||
remaining_ms: resolve_puzzle_level_time_limit_ms(next_grid_size),
|
||||
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,
|
||||
@@ -1382,6 +1683,100 @@ fn infer_tags_and_forbidden(source: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_form_seed_text(source: &str) -> Option<(String, String)> {
|
||||
let normalized_source = source.trim();
|
||||
let title_marker = "拼图标题:";
|
||||
let description_marker = "画面描述:";
|
||||
let title_start = normalized_source.find(title_marker)? + title_marker.len();
|
||||
let description_start = normalized_source.find(description_marker)?;
|
||||
if description_start <= title_start {
|
||||
return None;
|
||||
}
|
||||
|
||||
let title = normalize_required_string(&normalized_source[title_start..description_start]);
|
||||
let picture_description = normalize_required_string(
|
||||
&normalized_source[description_start + description_marker.len()..],
|
||||
);
|
||||
|
||||
match (title, picture_description) {
|
||||
(Some(title), Some(picture_description)) => Some((title, picture_description)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_form_tags_and_forbidden(title: &str, picture_description: &str) -> String {
|
||||
let mut tags = derive_form_theme_tags(title, picture_description);
|
||||
if tags.len() < PUZZLE_MIN_TAG_COUNT {
|
||||
for fallback in ["拼图", "插画", "清晰构图"] {
|
||||
if !tags.iter().any(|tag| tag == fallback) {
|
||||
tags.push(fallback.to_string());
|
||||
}
|
||||
if tags.len() >= PUZZLE_MIN_TAG_COUNT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
format!("{};禁止标题字", tags.join("、"))
|
||||
}
|
||||
|
||||
fn derive_form_theme_tags(title: &str, picture_description: &str) -> Vec<String> {
|
||||
let source = format!("{title} {picture_description}");
|
||||
let keyword_tags = [
|
||||
("猫", "猫咪"),
|
||||
("狗", "小狗"),
|
||||
("神庙", "神庙遗迹"),
|
||||
("遗迹", "神庙遗迹"),
|
||||
("森林", "童话森林"),
|
||||
("雨", "雨夜"),
|
||||
("夜", "夜景"),
|
||||
("城市", "城市奇景"),
|
||||
("蒸汽", "蒸汽城市"),
|
||||
("机械", "机械幻想"),
|
||||
("海", "海岸"),
|
||||
("花", "花园"),
|
||||
("雪", "雪景"),
|
||||
("龙", "幻想生物"),
|
||||
("灯", "暖灯"),
|
||||
];
|
||||
let mut tags = keyword_tags
|
||||
.into_iter()
|
||||
.filter(|(keyword, _)| source.contains(keyword))
|
||||
.map(|(_, tag)| tag.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for value in title
|
||||
.split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | '、' | ',' | ';' | ';'))
|
||||
.filter_map(normalize_required_string)
|
||||
{
|
||||
if value.chars().count() <= 8 {
|
||||
tags.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
normalize_theme_tags(tags)
|
||||
}
|
||||
|
||||
fn is_form_anchor_pack(anchor_pack: &PuzzleAnchorPack) -> bool {
|
||||
matches!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked)
|
||||
&& matches!(
|
||||
anchor_pack.visual_subject.status,
|
||||
PuzzleAnchorStatus::Locked
|
||||
)
|
||||
}
|
||||
|
||||
fn build_result_summary(anchor_pack: &PuzzleAnchorPack) -> String {
|
||||
if is_form_anchor_pack(anchor_pack) {
|
||||
return fallback_text(&anchor_pack.visual_subject.value, "画面主体");
|
||||
}
|
||||
|
||||
format!(
|
||||
"{},主体是{},氛围偏{}。",
|
||||
fallback_text(&anchor_pack.theme_promise.value, "梦幻题材"),
|
||||
fallback_text(&anchor_pack.visual_subject.value, "画面主体"),
|
||||
fallback_text(&anchor_pack.visual_mood.value, "温暖")
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_forbidden_directive(source: &str) -> String {
|
||||
if let Some((_, tail)) = source.split_once(';') {
|
||||
return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string());
|
||||
@@ -1390,6 +1785,12 @@ fn extract_forbidden_directive(source: &str) -> String {
|
||||
}
|
||||
|
||||
fn build_level_name(anchor_pack: &PuzzleAnchorPack, normalized_tags: &[String]) -> String {
|
||||
if is_form_anchor_pack(anchor_pack)
|
||||
&& let Some(title) = normalize_required_string(&anchor_pack.theme_promise.value)
|
||||
{
|
||||
return title;
|
||||
}
|
||||
|
||||
if let Some(tag) = normalized_tags.first() {
|
||||
return format!("{tag}拼图");
|
||||
}
|
||||
@@ -1970,7 +2371,11 @@ fn drag_group(
|
||||
Ok(affected_cells)
|
||||
}
|
||||
|
||||
fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> PuzzleRunSnapshot {
|
||||
fn with_next_board_at(
|
||||
run: &PuzzleRunSnapshot,
|
||||
next_board: PuzzleBoardSnapshot,
|
||||
now_ms: u64,
|
||||
) -> PuzzleRunSnapshot {
|
||||
let mut next_run = run.clone();
|
||||
let is_cleared = next_board.all_tiles_resolved;
|
||||
let next_level_status = if is_cleared {
|
||||
@@ -1982,13 +2387,10 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) ->
|
||||
if let Some(current_level) = next_run.current_level.as_mut() {
|
||||
current_level.board = next_board;
|
||||
if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared {
|
||||
let cleared_at_ms = current_unix_ms();
|
||||
current_level.cleared_at_ms = Some(cleared_at_ms);
|
||||
current_level.elapsed_ms = Some(
|
||||
cleared_at_ms
|
||||
.saturating_sub(current_level.started_at_ms)
|
||||
.max(1_000),
|
||||
);
|
||||
current_level.cleared_at_ms = Some(now_ms);
|
||||
current_level.elapsed_ms =
|
||||
Some(resolve_effective_elapsed_ms(current_level, now_ms).max(1_000));
|
||||
current_level.remaining_ms = 0;
|
||||
}
|
||||
current_level.status = next_level_status;
|
||||
}
|
||||
@@ -2011,6 +2413,10 @@ fn current_unix_ms() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn current_puzzle_unix_micros() -> i64 {
|
||||
(current_unix_ms() as i64).saturating_mul(1_000)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2079,6 +2485,53 @@ mod tests {
|
||||
assert!(!candidates[0].image_src.contains(&legacy_public_prefix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_seed_locks_title_and_picture_description_as_primary_anchors() {
|
||||
let anchor_pack = infer_anchor_pack(
|
||||
"拼图标题:暖灯猫街\n画面描述:一只猫在雨夜灯牌下回头。",
|
||||
None,
|
||||
);
|
||||
let draft = compile_result_draft(&anchor_pack, &[]);
|
||||
|
||||
assert_eq!(anchor_pack.theme_promise.value, "暖灯猫街");
|
||||
assert_eq!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked);
|
||||
assert_eq!(anchor_pack.visual_subject.value, "一只猫在雨夜灯牌下回头。");
|
||||
assert_eq!(
|
||||
anchor_pack.visual_subject.status,
|
||||
PuzzleAnchorStatus::Locked
|
||||
);
|
||||
assert_eq!(draft.level_name, "暖灯猫街");
|
||||
assert_eq!(draft.summary, "一只猫在雨夜灯牌下回头。");
|
||||
assert_eq!(
|
||||
draft
|
||||
.creator_intent
|
||||
.as_ref()
|
||||
.map(|intent| intent.source_mode.as_str()),
|
||||
Some("form")
|
||||
);
|
||||
assert!(draft.theme_tags.len() >= PUZZLE_MIN_TAG_COUNT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_seed_keeps_multiline_picture_description() {
|
||||
let anchor_pack = infer_anchor_pack(
|
||||
"拼图标题:雨夜猫街\n画面描述:一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。",
|
||||
None,
|
||||
);
|
||||
let draft = compile_result_draft(&anchor_pack, &[]);
|
||||
|
||||
assert_eq!(
|
||||
anchor_pack.visual_subject.value,
|
||||
"一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。"
|
||||
);
|
||||
assert_eq!(
|
||||
draft.summary,
|
||||
"一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。"
|
||||
);
|
||||
assert!(draft.theme_tags.iter().any(|tag| tag == "猫咪"));
|
||||
assert!(draft.theme_tags.iter().any(|tag| tag == "雨夜"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_similarity_score_uses_jaccard() {
|
||||
let score = tag_similarity_score(
|
||||
@@ -2396,6 +2849,59 @@ mod tests {
|
||||
assert!(board.all_tiles_resolved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_marks_running_level_failed_after_limit() {
|
||||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||||
let mut run =
|
||||
start_run_with_shuffle_seed("run-timeout".to_string(), &profile, 0, 11).expect("run");
|
||||
let level = run.current_level.as_mut().expect("level");
|
||||
level.started_at_ms = current_unix_ms().saturating_sub(level.time_limit_ms + 1_000);
|
||||
|
||||
let timed_run = resolve_puzzle_run_timer(run);
|
||||
let timed_level = timed_run.current_level.as_ref().expect("level");
|
||||
|
||||
assert_eq!(timed_level.status, PuzzleRuntimeLevelStatus::Failed);
|
||||
assert_eq!(timed_level.remaining_ms, 0);
|
||||
assert_eq!(timed_level.elapsed_ms, Some(timed_level.time_limit_ms));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_and_freeze_are_excluded_from_effective_timer() {
|
||||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||||
let mut run =
|
||||
start_run_with_shuffle_seed("run-freeze".to_string(), &profile, 0, 12).expect("run");
|
||||
let now_ms = current_unix_ms();
|
||||
let level = run.current_level.as_mut().expect("level");
|
||||
level.started_at_ms = now_ms.saturating_sub(30_000);
|
||||
level.paused_accumulated_ms = 8_000;
|
||||
level.pause_started_at_ms = Some(now_ms.saturating_sub(5_000));
|
||||
level.freeze_accumulated_ms = 4_000;
|
||||
level.freeze_started_at_ms = Some(now_ms.saturating_sub(3_000));
|
||||
level.freeze_until_ms = Some(now_ms.saturating_add(7_000));
|
||||
|
||||
let remaining_ms = resolve_puzzle_runtime_remaining_ms(level, now_ms);
|
||||
|
||||
assert_eq!(remaining_ms, level.time_limit_ms.saturating_sub(10_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_preview_can_keep_run_paused_until_overlay_closes() {
|
||||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||||
let run =
|
||||
start_run_with_shuffle_seed("run-reference".to_string(), &profile, 0, 13).expect("run");
|
||||
|
||||
let paused_run = set_puzzle_run_paused(&run, true).expect("pause");
|
||||
let still_paused_run = set_puzzle_run_paused(&paused_run, true).expect("reference pause");
|
||||
|
||||
assert!(
|
||||
still_paused_run
|
||||
.current_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.pause_started_at_ms)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_publish_overrides_updates_draft_truth() {
|
||||
let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙"));
|
||||
|
||||
Reference in New Issue
Block a user