This commit is contained in:
2026-05-01 00:33:39 +08:00
parent 61969c5116
commit fe02603ba1
68 changed files with 4586 additions and 748 deletions

View File

@@ -16,6 +16,10 @@ 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;
pub const PUZZLE_EXTEND_TIME_DURATION_MS: u64 = 60_000;
pub const PUZZLE_NEXT_LEVEL_MODE_SAME_WORK: &str = "sameWork";
pub const PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS: &str = "similarWorks";
pub const PUZZLE_NEXT_LEVEL_MODE_NONE: &str = "none";
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -310,6 +314,8 @@ pub struct PuzzleBoardSnapshot {
pub struct PuzzleRuntimeLevelSnapshot {
pub run_id: String,
pub level_index: u32,
#[serde(default)]
pub level_id: Option<String>,
pub grid_size: u32,
pub profile_id: String,
pub level_name: String,
@@ -343,7 +349,7 @@ pub struct PuzzleRuntimeLevelSnapshot {
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PuzzleRunSnapshot {
pub run_id: String,
pub entry_profile_id: String,
@@ -354,10 +360,33 @@ pub struct PuzzleRunSnapshot {
pub previous_level_tags: Vec<String>,
pub current_level: Option<PuzzleRuntimeLevelSnapshot>,
pub recommended_next_profile_id: Option<String>,
#[serde(default = "default_puzzle_next_level_mode")]
pub next_level_mode: String,
#[serde(default)]
pub next_level_profile_id: Option<String>,
#[serde(default)]
pub next_level_id: Option<String>,
#[serde(default)]
pub recommended_next_works: Vec<PuzzleRecommendedNextWork>,
#[serde(default)]
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PuzzleRecommendedNextWork {
pub profile_id: String,
pub level_name: String,
pub author_display_name: String,
pub theme_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub similarity_score: f32,
}
fn default_puzzle_next_level_mode() -> String {
PUZZLE_NEXT_LEVEL_MODE_NONE.to_string()
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentSessionCreateInput {
@@ -423,6 +452,7 @@ pub struct PuzzleGeneratedImagesSaveInput {
pub session_id: String,
pub owner_user_id: String,
pub level_id: Option<String>,
pub levels_json: Option<String>,
pub candidates_json: String,
pub saved_at_micros: i64,
}
@@ -906,22 +936,22 @@ pub fn build_form_draft_from_parts(
) -> PuzzleResultDraft {
let work_title = work_title.and_then(|value| normalize_required_string(&value));
let work_description = work_description.and_then(|value| normalize_required_string(&value));
let picture_description = picture_description.and_then(|value| normalize_required_string(&value));
let picture_description =
picture_description.and_then(|value| normalize_required_string(&value));
let title_for_tags = work_title.as_deref().unwrap_or("");
let picture_for_tags = picture_description.as_deref().unwrap_or("");
let mut tags = normalize_theme_tags(derive_form_theme_tags(title_for_tags, picture_for_tags));
if tags.is_empty() {
tags = vec!["拼图".to_string(), "插画".to_string(), "清晰构图".to_string()];
tags = vec![
"拼图".to_string(),
"插画".to_string(),
"清晰构图".to_string(),
];
}
let level_name = picture_description
.as_deref()
.map(|value| build_level_name_from_picture(value, &tags, 1))
.or_else(|| work_title.clone())
.unwrap_or_else(|| "未命名拼图".to_string());
let summary = work_description.clone().unwrap_or_default();
let level = PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
level_name: level_name.clone(),
level_name: String::new(),
picture_description: picture_description.clone().unwrap_or_default(),
candidates: Vec::new(),
selected_candidate_id: None,
@@ -934,7 +964,7 @@ pub fn build_form_draft_from_parts(
PuzzleResultDraft {
work_title: work_title.clone().unwrap_or_default(),
work_description: summary.clone(),
level_name,
level_name: String::new(),
summary,
theme_tags: tags,
forbidden_directives: Vec::new(),
@@ -1538,6 +1568,42 @@ pub fn apply_puzzle_freeze_time(
apply_puzzle_freeze_time_at(run, current_unix_ms())
}
pub fn extend_failed_puzzle_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::Failed {
return Err(PuzzleFieldError::InvalidOperation);
}
let total_consumed_before_extend = current_level
.time_limit_ms
.saturating_sub(PUZZLE_EXTEND_TIME_DURATION_MS);
current_level.status = PuzzleRuntimeLevelStatus::Playing;
current_level.elapsed_ms = None;
current_level.cleared_at_ms = None;
current_level.remaining_ms = PUZZLE_EXTEND_TIME_DURATION_MS;
current_level.started_at_ms = now_ms.saturating_sub(total_consumed_before_extend);
current_level.paused_accumulated_ms = 0;
current_level.pause_started_at_ms = None;
current_level.freeze_accumulated_ms = 0;
current_level.freeze_started_at_ms = None;
current_level.freeze_until_ms = None;
Ok(next_run)
}
pub fn extend_failed_puzzle_time(
run: &PuzzleRunSnapshot,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
extend_failed_puzzle_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)
}
@@ -1625,6 +1691,10 @@ pub fn start_run_with_shuffle_seed_at(
current_level: Some(PuzzleRuntimeLevelSnapshot {
run_id,
level_index: cleared_level_count + 1,
level_id: entry_profile
.levels
.first()
.map(|level| level.level_id.clone()),
grid_size,
profile_id: entry_profile.profile_id.clone(),
level_name: entry_profile.level_name.clone(),
@@ -1646,6 +1716,10 @@ pub fn start_run_with_shuffle_seed_at(
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
next_level_mode: default_puzzle_next_level_mode(),
next_level_profile_id: None,
next_level_id: None,
recommended_next_works: Vec::new(),
leaderboard_entries: Vec::new(),
})
}
@@ -1886,6 +1960,10 @@ pub fn advance_next_level_at(
current_level: Some(PuzzleRuntimeLevelSnapshot {
run_id: run.run_id.clone(),
level_index: run.current_level_index + 1,
level_id: next_profile
.levels
.first()
.map(|level| level.level_id.clone()),
grid_size: next_grid_size,
profile_id: next_profile.profile_id.clone(),
level_name: next_profile.level_name.clone(),
@@ -1907,15 +1985,98 @@ pub fn advance_next_level_at(
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
next_level_mode: default_puzzle_next_level_mode(),
next_level_profile_id: None,
next_level_id: None,
recommended_next_works: Vec::new(),
leaderboard_entries: Vec::new(),
})
}
pub fn selected_profile_level_after_index(
profile: &PuzzleWorkProfile,
current_level_index: u32,
) -> Option<PuzzleDraftLevel> {
if current_level_index == 0 {
return None;
}
let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
.unwrap_or_else(|_| profile.levels.clone());
normalized_levels.get(current_level_index as usize).cloned()
}
pub fn selected_profile_level_after_runtime_level(
profile: &PuzzleWorkProfile,
current_level: &PuzzleRuntimeLevelSnapshot,
) -> Option<PuzzleDraftLevel> {
let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
.unwrap_or_else(|_| profile.levels.clone());
if normalized_levels.len() <= 1 {
return None;
}
let matched_index = current_level
.level_id
.as_ref()
.and_then(|level_id| {
normalized_levels
.iter()
.position(|level| level.level_id == *level_id)
})
.or_else(|| {
current_level
.cover_image_src
.as_ref()
.and_then(|cover_image_src| {
normalized_levels.iter().position(|level| {
level.cover_image_src.as_ref() == Some(cover_image_src)
&& level.level_name == current_level.level_name
})
})
})
.or_else(|| {
normalized_levels.iter().position(|level| {
level.level_name == current_level.level_name
&& level.cover_image_src == current_level.cover_image_src
})
})
.or_else(|| {
current_level.level_index.checked_sub(1).and_then(|index| {
((index as usize) < normalized_levels.len()).then_some(index as usize)
})
})?;
normalized_levels.get(matched_index + 1).cloned()
}
pub fn selected_profile_level_index(profile: &PuzzleWorkProfile, level_id: &str) -> Option<usize> {
let target_level_id = normalize_required_string(level_id)?;
let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
.unwrap_or_else(|_| profile.levels.clone());
normalized_levels
.iter()
.position(|level| level.level_id == target_level_id)
}
pub fn select_next_profile<'a>(
current_profile: &PuzzleWorkProfile,
played_profile_ids: &[String],
candidates: &'a [PuzzleWorkProfile],
) -> Option<&'a PuzzleWorkProfile> {
select_next_profiles(current_profile, played_profile_ids, candidates, 1)
.into_iter()
.next()
}
pub fn select_next_profiles<'a>(
current_profile: &PuzzleWorkProfile,
played_profile_ids: &[String],
candidates: &'a [PuzzleWorkProfile],
limit: usize,
) -> Vec<&'a PuzzleWorkProfile> {
if limit == 0 {
return Vec::new();
}
let mut available = candidates
.iter()
.filter(|candidate| {
@@ -1936,23 +2097,25 @@ pub fn select_next_profile<'a>(
available.retain(|candidate| candidate.profile_id != *last_played);
}
available.into_iter().max_by(|left, right| {
available.sort_by(|left, right| {
let left_score = recommendation_score(current_profile, left);
let right_score = recommendation_score(current_profile, right);
left_score
.partial_cmp(&right_score)
right_score
.partial_cmp(&left_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
tag_similarity_score(&current_profile.theme_tags, &left.theme_tags)
tag_similarity_score(&current_profile.theme_tags, &right.theme_tags)
.partial_cmp(&tag_similarity_score(
&current_profile.theme_tags,
&right.theme_tags,
&left.theme_tags,
))
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| right.play_count.cmp(&left.play_count))
.then_with(|| left.updated_at_micros.cmp(&right.updated_at_micros))
})
.then_with(|| left.play_count.cmp(&right.play_count))
.then_with(|| right.updated_at_micros.cmp(&left.updated_at_micros))
});
available.truncate(limit);
available
}
pub fn recommendation_score(
@@ -1983,10 +2146,169 @@ pub fn tag_similarity_score(left_tags: &[String], right_tags: &[String]) -> f32
if union <= f32::EPSILON {
0.0
} else {
intersection / union
let lexical_score = intersection / union;
// 中文注释:优先复用 RPG build 标签的属性亲和度语义模型;拼图自有标签未命中时保留 Jaccard 兜底。
rpg_build_tag_set_similarity(&left_set, &right_set)
.map(|semantic_score| semantic_score.max(lexical_score))
.unwrap_or(lexical_score)
}
}
#[derive(Clone, Copy)]
struct RpgBuildTagSemanticDefinition {
category: &'static str,
affinity: [f32; 6],
}
fn rpg_affinity(strength: f32, agility: f32, intelligence: f32, spirit: f32) -> [f32; 6] {
[
strength * 0.72 + spirit * 0.28,
agility * 0.88 + intelligence * 0.12,
intelligence * 0.78 + agility * 0.22,
strength * 0.62 + agility * 0.18 + intelligence * 0.2,
spirit * 0.72 + intelligence * 0.28,
spirit * 0.74 + strength * 0.26,
]
}
fn resolve_rpg_build_tag_semantic(tag: &str) -> Option<RpgBuildTagSemanticDefinition> {
let normalized = tag.trim().to_lowercase();
let value = normalized.as_str();
let definition = match value {
"quickblade" | "快剑" | "快刀" | "决斗者" => {
("style", rpg_affinity(0.35, 1.0, 0.1, 0.05))
}
"combo" | "连段" | "连击" | "连锁" => ("style", rpg_affinity(0.3, 0.92, 0.18, 0.08)),
"dash" | "突进" | "冲锋" => ("style", rpg_affinity(0.45, 0.95, 0.0, 0.0)),
"pursuit" | "追击" => ("style", rpg_affinity(0.38, 0.88, 0.08, 0.02)),
"swiftstrike" | "快袭" | "刺袭" | "伏击" => {
("style", rpg_affinity(0.22, 0.98, 0.12, 0.04))
}
"ranged" | "远射" | "射击" | "箭矢" => {
("style", rpg_affinity(0.18, 0.82, 0.34, 0.08))
}
"guerrilla" | "游击" | "骚扰" => ("style", rpg_affinity(0.24, 0.9, 0.28, 0.12)),
"mobility" | "机动" | "敏捷" | "灵活" => {
("style", rpg_affinity(0.18, 1.0, 0.08, 0.08))
}
"windrun" | "风行" | "疾行" => ("style", rpg_affinity(0.08, 1.0, 0.1, 0.1)),
"heavyhit" | "重击" => ("style", rpg_affinity(1.0, 0.28, 0.02, 0.04)),
"burst" | "爆发" => ("style", rpg_affinity(0.72, 0.58, 0.36, 0.08)),
"armorbreak" | "破甲" => ("style", rpg_affinity(0.92, 0.28, 0.08, 0.02)),
"pressure" | "压制" => ("style", rpg_affinity(0.62, 0.64, 0.1, 0.08)),
"bloodrush" | "压血" => ("resource", rpg_affinity(0.84, 0.54, 0.04, 0.18)),
"guard" | "守御" | "守卫" | "防御" => {
("defense", rpg_affinity(0.7, 0.18, 0.04, 0.72))
}
"barrier" | "护体" | "护罩" | "护盾" => {
("defense", rpg_affinity(0.48, 0.08, 0.2, 0.92))
}
"heavyarmor" | "重甲" => ("defense", rpg_affinity(0.88, 0.04, 0.02, 0.54)),
"counter" | "反击" | "回击" => ("defense", rpg_affinity(0.66, 0.46, 0.14, 0.36)),
"banish" | "镇邪" => ("defense", rpg_affinity(0.24, 0.06, 0.54, 0.88)),
"caster" | "法修" | "法师" => ("element", rpg_affinity(0.0, 0.1, 1.0, 0.6)),
"mana" | "法力" => ("resource", rpg_affinity(0.02, 0.08, 0.94, 0.74)),
"thunder" | "雷法" => ("element", rpg_affinity(0.06, 0.24, 0.96, 0.42)),
"formation" | "符阵" | "法阵" => ("element", rpg_affinity(0.08, 0.12, 0.82, 0.96)),
"control" | "控场" | "控制" => ("style", rpg_affinity(0.12, 0.34, 0.78, 0.72)),
"overload" | "过载" => ("resource", rpg_affinity(0.14, 0.18, 0.92, 0.38)),
"heal" | "回复" | "治疗" => ("resource", rpg_affinity(0.02, 0.08, 0.56, 1.0)),
"support" | "护持" | "支援" | "祝福" => {
("resource", rpg_affinity(0.14, 0.14, 0.58, 0.98))
}
"sustain" | "续战" => ("resource", rpg_affinity(0.34, 0.18, 0.22, 0.9)),
"fate" | "命纹" => ("flow", rpg_affinity(0.08, 0.22, 0.72, 0.84)),
"fortune" | "机缘" => ("flow", rpg_affinity(0.06, 0.34, 0.7, 0.78)),
"cooldown" | "冷却" => ("resource", rpg_affinity(0.04, 0.46, 0.82, 0.4)),
"command" | "统御" => ("flow", rpg_affinity(0.38, 0.26, 0.72, 0.82)),
"balanced" | "均衡" | "平衡" | "全能" => {
("flow", rpg_affinity(0.58, 0.58, 0.58, 0.58))
}
"craft" | "工巧" | "工艺" => ("craft", rpg_affinity(0.24, 0.16, 0.74, 0.5)),
"alchemy" | "炼药" | "药剂" => ("craft", rpg_affinity(0.08, 0.16, 0.84, 0.76)),
"vanguard" | "先锋" => ("flow", rpg_affinity(0.82, 0.44, 0.08, 0.34)),
"berserk" | "狂战" => ("flow", rpg_affinity(0.98, 0.42, 0.0, 0.22)),
"spellblade" | "法剑" => ("flow", rpg_affinity(0.42, 0.42, 0.88, 0.38)),
"paladin" | "圣佑" | "圣骑士" => ("flow", rpg_affinity(0.58, 0.12, 0.42, 0.96)),
"fortress" | "堡垒" => ("flow", rpg_affinity(0.94, 0.04, 0.08, 0.82)),
"starter" | "起手" => ("flow", rpg_affinity(0.42, 0.42, 0.42, 0.42)),
_ => return None,
};
Some(RpgBuildTagSemanticDefinition {
category: definition.0,
affinity: definition.1,
})
}
fn normalized_affinity_dot(left: [f32; 6], right: [f32; 6]) -> f32 {
let left_magnitude = left.iter().map(|value| value * value).sum::<f32>().sqrt();
let right_magnitude = right.iter().map(|value| value * value).sum::<f32>().sqrt();
if left_magnitude <= 0.0001 || right_magnitude <= 0.0001 {
return 0.0;
}
left.iter()
.zip(right.iter())
.map(|(left_value, right_value)| {
(left_value / left_magnitude) * (right_value / right_magnitude)
})
.sum::<f32>()
}
fn rpg_build_tag_similarity(
left: RpgBuildTagSemanticDefinition,
right: RpgBuildTagSemanticDefinition,
) -> f32 {
let category_bonus = if left.category == right.category {
0.08
} else {
0.0
};
(normalized_affinity_dot(left.affinity, right.affinity) + category_bonus).min(1.0)
}
fn rpg_build_tag_directional_similarity(
left: &[RpgBuildTagSemanticDefinition],
right: &[RpgBuildTagSemanticDefinition],
) -> f32 {
if left.is_empty() || right.is_empty() {
return 0.0;
}
let total = left
.iter()
.map(|left_definition| {
right
.iter()
.map(|right_definition| {
rpg_build_tag_similarity(*left_definition, *right_definition)
})
.fold(0.0_f32, f32::max)
})
.sum::<f32>();
total / left.len() as f32
}
fn rpg_build_tag_set_similarity(
left_tags: &BTreeSet<String>,
right_tags: &BTreeSet<String>,
) -> Option<f32> {
let left_definitions = left_tags
.iter()
.filter_map(|tag| resolve_rpg_build_tag_semantic(tag))
.collect::<Vec<_>>();
let right_definitions = right_tags
.iter()
.filter_map(|tag| resolve_rpg_build_tag_semantic(tag))
.collect::<Vec<_>>();
if left_definitions.is_empty() || right_definitions.is_empty() {
return None;
}
Some(
(rpg_build_tag_directional_similarity(&left_definitions, &right_definitions)
+ rpg_build_tag_directional_similarity(&right_definitions, &left_definitions))
/ 2.0,
)
}
pub fn normalize_theme_tags(tags: Vec<String>) -> Vec<String> {
let alias_map = BTreeMap::from([
("蒸汽", "蒸汽城市"),
@@ -2172,7 +2494,7 @@ fn derive_form_theme_tags(title: &str, picture_description: &str) -> Vec<String>
fn is_form_anchor_pack(anchor_pack: &PuzzleAnchorPack) -> bool {
matches!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked)
&& matches!(
|| matches!(
anchor_pack.visual_subject.status,
PuzzleAnchorStatus::Locked
)
@@ -2902,6 +3224,24 @@ mod tests {
assert_eq!(resolve_puzzle_grid_size(3), 4);
}
#[test]
fn form_draft_preserves_partial_initial_fields() {
let seed_text = "作品名称:月台拼图\n作品描述:";
let anchor_pack = infer_anchor_pack(seed_text, Some(seed_text));
let draft = build_form_draft_from_seed(&anchor_pack, Some(seed_text));
let form_draft = draft.form_draft.expect("form draft should exist");
assert_eq!(form_draft.work_title.as_deref(), Some("月台拼图"));
assert_eq!(form_draft.work_description, None);
assert_eq!(form_draft.picture_description, None);
assert_eq!(draft.work_title, "月台拼图");
assert_eq!(draft.work_description, "");
assert_eq!(draft.level_name, "");
assert_eq!(draft.levels[0].level_name, "");
assert_eq!(draft.anchor_pack.theme_promise.value, "月台拼图");
assert_eq!(draft.anchor_pack.visual_subject.value, "");
}
#[test]
fn normalize_theme_tags_dedups_aliases() {
assert_eq!(
@@ -2993,7 +3333,7 @@ mod tests {
}
#[test]
fn tag_similarity_score_uses_jaccard() {
fn tag_similarity_score_uses_jaccard_fallback() {
let score = tag_similarity_score(
&["蒸汽城市".to_string(), "雨夜".to_string()],
&["蒸汽城市".to_string(), "猫咪".to_string()],
@@ -3001,6 +3341,13 @@ mod tests {
assert!((score - 0.3333).abs() < 0.01);
}
#[test]
fn tag_similarity_score_prefers_rpg_build_semantic_affinity() {
let score = tag_similarity_score(&["快剑".to_string()], &["连击".to_string()]);
assert!(score > 0.75);
}
#[test]
fn select_next_profile_prefers_same_tags_and_author() {
let current = build_published_profile("a", "owner-a", vec!["蒸汽城市", "雨夜"]);