This commit is contained in:
2026-04-26 14:27:48 +08:00
parent f68f4914ec
commit ea33413187
155 changed files with 8130 additions and 1740 deletions

View File

@@ -0,0 +1,28 @@
{
"next_user_id": 2,
"users_by_username": {
"phone_00000002": {
"user": {
"id": "user_00000001",
"public_user_code": "SY-00000001",
"username": "phone_00000002",
"display_name": "138****8111",
"phone_number_masked": "138****8111",
"login_method": "Phone",
"binding_status": "Active",
"wechat_bound": false,
"token_version": 1
},
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$qnArSgOrZvcQxap4KAMMnA$+K+gQgf7h0jQibJLuvAlOeHnNNYutTvLVDAyo1hqS/o",
"password_login_enabled": false,
"phone_number": "+8613800138111"
}
},
"phone_to_user_id": {
"+8613800138111": "user_00000001"
},
"sessions_by_id": {},
"session_id_by_refresh_token_hash": {},
"wechat_identity_by_provider_uid": {},
"user_id_by_provider_union_id": {}
}

View File

@@ -0,0 +1,28 @@
{
"next_user_id": 2,
"users_by_username": {
"phone_00000002": {
"user": {
"id": "user_00000001",
"public_user_code": "SY-00000001",
"username": "phone_00000002",
"display_name": "138****8112",
"phone_number_masked": "138****8112",
"login_method": "Phone",
"binding_status": "Active",
"wechat_bound": false,
"token_version": 1
},
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$0HR2g/fKOw9EFHz7BuYtGg$cpXb5KBwbEXPxPJHA4Bk1U7NtM97GhGTq7VK6jCJ+lA",
"password_login_enabled": false,
"phone_number": "+8613800138112"
}
},
"phone_to_user_id": {
"+8613800138112": "user_00000001"
},
"sessions_by_id": {},
"session_id_by_refresh_token_hash": {},
"wechat_identity_by_provider_uid": {},
"user_id_by_provider_union_id": {}
}

View File

@@ -0,0 +1,28 @@
{
"next_user_id": 2,
"users_by_username": {
"phone_00000002": {
"user": {
"id": "user_00000001",
"public_user_code": "SY-00000001",
"username": "phone_00000002",
"display_name": "138****8110",
"phone_number_masked": "138****8110",
"login_method": "Phone",
"binding_status": "Active",
"wechat_bound": false,
"token_version": 1
},
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$fEeSrVyialDeb8rarDSpdA$HFihZiuCOyaz8F5iNukmobeiHI/EpYWdeQzhbIYR4zk",
"password_login_enabled": false,
"phone_number": "+8613800138110"
}
},
"phone_to_user_id": {
"+8613800138110": "user_00000001"
},
"sessions_by_id": {},
"session_id_by_refresh_token_hash": {},
"wechat_identity_by_provider_uid": {},
"user_id_by_provider_union_id": {}
}

View File

@@ -0,0 +1,56 @@
{
"next_user_id": 2,
"users_by_username": {
"phone_00000002": {
"user": {
"id": "user_00000001",
"public_user_code": "SY-00000001",
"username": "phone_00000002",
"display_name": "138****8000",
"phone_number_masked": "138****8000",
"login_method": "Phone",
"binding_status": "Active",
"wechat_bound": false,
"token_version": 1
},
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$hoXmK/LzABj2QfWZSO3SNA$Qg71V2iZCPyLOsoQLffiCv3KPkWVNSAsP6IooTIXi/w",
"password_login_enabled": false,
"phone_number": "+8613800138000"
}
},
"phone_to_user_id": {
"+8613800138000": "user_00000001"
},
"sessions_by_id": {
"usess_52522126b58d40e3b9e503808dd11e2c": {
"session": {
"session_id": "usess_52522126b58d40e3b9e503808dd11e2c",
"user_id": "user_00000001",
"refresh_token_hash": "f42140526caea3e4a9f533bcc2d8799feae4f96769ea975ef771b1ae11e4dbe9",
"issued_by_provider": "Phone",
"client_info": {
"client_type": "web_browser",
"client_runtime": "unknown",
"client_platform": "unknown",
"client_instance_id": null,
"device_fingerprint": null,
"device_display_name": "未知设备 / 未知客户端",
"mini_program_app_id": null,
"mini_program_env": null,
"user_agent": null,
"ip": null
},
"expires_at": "2026-05-25T15:41:01.0856147Z",
"revoked_at": null,
"created_at": "2026-04-25T15:41:01.0856147Z",
"updated_at": "2026-04-25T15:41:01.0856147Z",
"last_seen_at": "2026-04-25T15:41:01.0856147Z"
}
}
},
"session_id_by_refresh_token_hash": {
"f42140526caea3e4a9f533bcc2d8799feae4f96769ea975ef771b1ae11e4dbe9": "usess_52522126b58d40e3b9e503808dd11e2c"
},
"wechat_identity_by_provider_uid": {},
"user_id_by_provider_union_id": {}
}

View File

@@ -95,7 +95,8 @@ use crate::{
runtime_inventory::get_runtime_inventory_state,
runtime_profile::{
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
get_profile_recharge_center, get_profile_wallet_ledger,
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger,
redeem_profile_referral_invite_code,
},
runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
@@ -820,6 +821,34 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(

View File

@@ -434,13 +434,7 @@ pub async fn execute_big_fish_action(
let now = current_utc_micros();
let session = match payload.action.trim() {
"big_fish_compile_draft" => {
compile_big_fish_draft_with_all_assets(
&state,
session_id,
owner_user_id,
now,
)
.await
compile_big_fish_draft_with_all_assets(&state, session_id, owner_user_id, now).await
}
"big_fish_generate_level_main_image" => {
let asset_url = generate_big_fish_formal_asset(

View File

@@ -74,14 +74,9 @@ pub async fn generate_character_visual(
// 旧资产工坊接口没有显式 Bearer 头Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。
let owner_user_id = "asset-tool".to_string();
let task_id = generate_ai_task_id(current_utc_micros());
let prompt = build_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let fallback_prompt = build_fallback_moderation_safe_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let prompt = build_character_visual_prompt(payload.prompt_text.as_str());
let fallback_prompt =
build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str());
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
@@ -296,27 +291,20 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
owner_user_id: &str,
character_id: &str,
prompt_text: &str,
character_brief_text: Option<&str>,
) -> Result<GeneratedCharacterPrimaryVisual, AppError> {
let payload = CharacterVisualGenerateRequest {
character_id: character_id.to_string(),
source_mode: shared_contracts::assets::CharacterVisualSourceMode::TextToImage,
prompt_text: prompt_text.to_string(),
character_brief_text: character_brief_text.map(ToOwned::to_owned),
reference_image_data_urls: Vec::new(),
candidate_count: 1,
image_model: CHARACTER_VISUAL_MODEL.to_string(),
size: "1024*1024".to_string(),
};
let task_id = generate_ai_task_id(current_utc_micros());
let prompt = build_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let fallback_prompt = build_fallback_moderation_safe_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let prompt = build_character_visual_prompt(payload.prompt_text.as_str());
let fallback_prompt =
build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str());
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
@@ -2068,7 +2056,7 @@ mod tests {
#[test]
fn build_character_visual_prompt_keeps_generation_constraints() {
let prompt = build_character_visual_prompt("潮雾港向导", Some("旧港守望者"));
let prompt = build_character_visual_prompt("潮雾港向导");
assert!(prompt.contains("潮雾港向导"));
assert!(prompt.contains("右向斜侧身"));
@@ -2077,10 +2065,8 @@ mod tests {
#[test]
fn fallback_character_visual_prompt_removes_risky_specific_names() {
let prompt = build_fallback_moderation_safe_character_visual_prompt(
"艾瑞克,银发剑士,红色长披风",
Some("某知名设定参考"),
);
let prompt =
build_fallback_moderation_safe_character_visual_prompt("艾瑞克,银发剑士,红色长披风");
assert!(prompt.contains("原创"));
assert!(prompt.contains("不参考任何现有"));

View File

@@ -1613,7 +1613,6 @@ async fn generate_draft_foundation_role_visuals(
task_owner_user_id.as_str(),
role_ref.role_id.as_str(),
role_ref.prompt.as_str(),
Some(role_ref.name.as_str()),
)
.await
};
@@ -2429,8 +2428,8 @@ fn log_custom_world_publish_gate_diagnostics(
has_draft_profile = session.draft_profile.as_object().map(|value| !value.is_empty()).unwrap_or(false),
has_result_preview = session.result_preview.is_some(),
preview_source = session.result_preview.as_ref().and_then(|value| value.get("source")).and_then(serde_json::Value::as_str).unwrap_or(""),
has_world_hook = has_custom_world_publish_text(profile, &["worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise.hook", "settingText"]),
has_player_premise = has_custom_world_publish_text(profile, &["playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation"]),
has_world_hook = has_custom_world_publish_text(profile, &["worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise", "anchorContent.worldPromise.hook", "settingText"]),
has_player_premise = has_custom_world_publish_text(profile, &["playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation"]),
has_core_conflicts = has_custom_world_non_empty_text_array(profile, "coreConflicts"),
has_main_chapter = has_custom_world_array(profile, "chapters") || has_custom_world_array(profile, "sceneChapterBlueprints") || has_custom_world_array(profile, "sceneChapters"),
has_scene_act = has_custom_world_scene_act(profile),

View File

@@ -99,113 +99,25 @@ pub(crate) struct PromptDynamicStateInference {
judgement_summary: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WorldPromiseValue {
#[serde(default)]
hook: String,
#[serde(default)]
differentiator: String,
#[serde(default)]
desired_experience: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PlayerFantasyValue {
#[serde(default)]
player_role: String,
#[serde(default)]
core_pursuit: String,
#[serde(default)]
fear_of_loss: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ThemeBoundaryValue {
#[serde(default)]
tone_keywords: Vec<String>,
#[serde(default)]
aesthetic_directives: Vec<String>,
#[serde(default)]
forbidden_directives: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PlayerEntryPointValue {
#[serde(default)]
opening_identity: String,
#[serde(default)]
opening_problem: String,
#[serde(default)]
entry_motivation: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoreConflictValue {
#[serde(default)]
surface_conflicts: Vec<String>,
#[serde(default)]
hidden_crisis: String,
#[serde(default)]
first_touched_conflict: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeyRelationshipValue {
#[serde(default)]
pairs: String,
#[serde(default)]
relationship_type: String,
#[serde(default)]
secret_or_cost: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct HiddenLineValue {
#[serde(default)]
hidden_truths: Vec<String>,
#[serde(default)]
misdirection_hints: Vec<String>,
#[serde(default)]
reveal_pacing: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IconicElementValue {
#[serde(default)]
iconic_motifs: Vec<String>,
#[serde(default)]
institutions_or_artifacts: Vec<String>,
#[serde(default)]
hard_rules: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EightAnchorContent {
#[serde(default)]
world_promise: Option<WorldPromiseValue>,
world_promise: Option<String>,
#[serde(default)]
player_fantasy: Option<PlayerFantasyValue>,
player_fantasy: Option<String>,
#[serde(default)]
theme_boundary: Option<ThemeBoundaryValue>,
theme_boundary: Option<String>,
#[serde(default)]
player_entry_point: Option<PlayerEntryPointValue>,
player_entry_point: Option<String>,
#[serde(default)]
core_conflict: Option<CoreConflictValue>,
core_conflict: Option<String>,
#[serde(default)]
key_relationships: Vec<KeyRelationshipValue>,
key_relationships: Option<String>,
#[serde(default)]
hidden_lines: Option<HiddenLineValue>,
hidden_lines: Option<String>,
#[serde(default)]
iconic_elements: Option<IconicElementValue>,
iconic_elements: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -814,137 +726,127 @@ fn build_chat_history(messages: &[CustomWorldAgentMessageRecord]) -> Vec<JsonVal
}
fn normalize_eight_anchor_content(value: &JsonValue) -> EightAnchorContent {
serde_json::from_value::<EightAnchorContent>(value.clone()).unwrap_or_default()
// Agent session 的新结构要求每个锚点只保存一段文本;这里兼容旧对象/数组存档,
// 读取时压缩成单字段,写回时仍由 EightAnchorContent 序列化为新结构。
EightAnchorContent {
world_promise: normalize_anchor_text(value.get("worldPromise")),
player_fantasy: normalize_anchor_text(value.get("playerFantasy")),
theme_boundary: normalize_anchor_text(value.get("themeBoundary")),
player_entry_point: normalize_anchor_text(value.get("playerEntryPoint")),
core_conflict: normalize_anchor_text(value.get("coreConflict")),
key_relationships: normalize_anchor_text(value.get("keyRelationships")),
hidden_lines: normalize_anchor_text(value.get("hiddenLines")),
iconic_elements: normalize_anchor_text(value.get("iconicElements")),
}
}
fn normalize_anchor_text(value: Option<&JsonValue>) -> Option<String> {
let normalized = compact_json_anchor_text(value?)?;
Some(clamp_text(normalized.as_str(), 180))
}
fn compact_json_anchor_text(value: &JsonValue) -> Option<String> {
match value {
JsonValue::Null => None,
JsonValue::String(text) => {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
(!normalized.trim().is_empty()).then_some(normalized.trim().to_string())
}
JsonValue::Array(items) => {
let values = items
.iter()
.filter_map(compact_json_anchor_text)
.collect::<Vec<_>>();
let compacted = dedupe_string_list(values, 8).join("");
(!compacted.trim().is_empty()).then_some(compacted)
}
JsonValue::Object(object) => {
let values = object
.values()
.filter_map(compact_json_anchor_text)
.collect::<Vec<_>>();
let compacted = dedupe_string_list(values, 8).join("");
(!compacted.trim().is_empty()).then_some(compacted)
}
JsonValue::Bool(value) => Some(value.to_string()),
JsonValue::Number(value) => Some(value.to_string()),
}
}
fn split_anchor_phrases(value: Option<&str>) -> Vec<String> {
value
.unwrap_or_default()
.split(['', ';', '、', ',', '', '\n'])
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string)
.collect()
}
fn build_creator_intent_from_eight_anchor_content(
anchor_content: &EightAnchorContent,
) -> CreatorIntentRecord {
let key_characters = anchor_content
let key_relationship_text = anchor_content
.key_relationships
.iter()
.enumerate()
.map(|(index, entry)| {
let (lead_name, relation_to_player) = split_relationship_pair(entry.pairs.as_str());
CreatorCharacterSeedRecord {
id: format!("creator-character-{}", index + 1),
name: if lead_name.is_empty() {
format!("关键人物{}", index + 1)
} else {
lead_name
},
role: entry.relationship_type.clone(),
public_mask: String::new(),
hidden_hook: entry.secret_or_cost.clone(),
relation_to_player,
notes: String::new(),
}
})
.collect::<Vec<_>>();
let core_conflicts = anchor_content
.core_conflict
.as_ref()
.map(|value| {
value
.surface_conflicts
.iter()
.cloned()
.chain(
(!value.hidden_crisis.trim().is_empty()).then_some(value.hidden_crisis.clone()),
)
.collect::<Vec<_>>()
})
.unwrap_or_default();
.as_deref()
.unwrap_or_default()
.trim()
.to_string();
let key_characters = if key_relationship_text.is_empty() {
Vec::new()
} else {
let (lead_name, relation_to_player) =
split_relationship_pair(key_relationship_text.as_str());
vec![CreatorCharacterSeedRecord {
id: "creator-character-1".to_string(),
name: if lead_name.is_empty() {
"关键人物1".to_string()
} else {
lead_name
},
role: key_relationship_text.clone(),
public_mask: String::new(),
hidden_hook: key_relationship_text.clone(),
relation_to_player,
notes: String::new(),
}]
};
CreatorIntentRecord {
source_mode: "freeform".to_string(),
raw_setting_text: compact_lines([
anchor_content
.world_promise
.as_ref()
.map(|value| value.differentiator.as_str()),
anchor_content
.player_fantasy
.as_ref()
.map(|value| value.core_pursuit.as_str()),
anchor_content
.hidden_lines
.as_ref()
.and_then(|value| value.hidden_truths.first().map(String::as_str)),
anchor_content.world_promise.as_deref(),
anchor_content.player_fantasy.as_deref(),
anchor_content.hidden_lines.as_deref(),
]),
world_hook: compact_lines([
anchor_content
.world_promise
.as_ref()
.map(|value| value.hook.as_str()),
anchor_content
.world_promise
.as_ref()
.map(|value| value.differentiator.as_str()),
]),
theme_keywords: anchor_content
.theme_boundary
.as_ref()
.map(|value| value.tone_keywords.clone())
.unwrap_or_default(),
tone_directives: anchor_content
.theme_boundary
.as_ref()
.map(|value| value.aesthetic_directives.clone())
.unwrap_or_default(),
world_hook: anchor_content.world_promise.clone().unwrap_or_default(),
theme_keywords: split_anchor_phrases(anchor_content.theme_boundary.as_deref()),
tone_directives: split_anchor_phrases(anchor_content.theme_boundary.as_deref()),
player_premise: compact_lines([
anchor_content
.player_fantasy
.as_ref()
.map(|value| value.player_role.as_str()),
anchor_content
.player_entry_point
.as_ref()
.map(|value| value.opening_identity.as_str()),
anchor_content.player_fantasy.as_deref(),
anchor_content.player_entry_point.as_deref(),
]),
opening_situation: compact_lines([
anchor_content
.player_entry_point
.as_ref()
.map(|value| value.opening_problem.as_str()),
anchor_content
.player_entry_point
.as_ref()
.map(|value| value.entry_motivation.as_str()),
]),
core_conflicts: dedupe_string_list(core_conflicts, 6),
opening_situation: anchor_content
.player_entry_point
.clone()
.unwrap_or_default(),
core_conflicts: dedupe_string_list(
split_anchor_phrases(anchor_content.core_conflict.as_deref()),
6,
),
key_characters,
key_landmarks: Vec::new(),
iconic_elements: dedupe_string_list(
anchor_content
.iconic_elements
.as_ref()
.map(|value| {
value
.iconic_motifs
.iter()
.cloned()
.chain(value.institutions_or_artifacts.iter().cloned())
.collect::<Vec<_>>()
})
.unwrap_or_default(),
split_anchor_phrases(anchor_content.iconic_elements.as_deref()),
8,
),
forbidden_directives: dedupe_string_list(
anchor_content
.theme_boundary
.as_ref()
.map(|value| value.forbidden_directives.clone())
.unwrap_or_default()
split_anchor_phrases(anchor_content.theme_boundary.as_deref())
.into_iter()
.chain(
anchor_content
.iconic_elements
.as_ref()
.map(|value| value.hard_rules.clone())
.unwrap_or_default(),
)
.chain(split_anchor_phrases(
anchor_content.iconic_elements.as_deref(),
))
.filter(|value| contains_any(value, &["避免", "不要", "禁止", "不能", "硬规则"]))
.collect::<Vec<_>>(),
8,
),
@@ -1370,36 +1272,12 @@ fn detect_drift_risk(
let filled_count = [
anchor_content.world_promise.is_some(),
anchor_content.player_fantasy.is_some(),
anchor_content
.theme_boundary
.as_ref()
.map(|value| {
!value.tone_keywords.is_empty()
|| !value.aesthetic_directives.is_empty()
|| !value.forbidden_directives.is_empty()
})
.unwrap_or(false),
anchor_content.theme_boundary.is_some(),
anchor_content.player_entry_point.is_some(),
anchor_content.core_conflict.is_some(),
!anchor_content.key_relationships.is_empty(),
anchor_content
.hidden_lines
.as_ref()
.map(|value| {
!value.hidden_truths.is_empty()
|| !value.misdirection_hints.is_empty()
|| !value.reveal_pacing.trim().is_empty()
})
.unwrap_or(false),
anchor_content
.iconic_elements
.as_ref()
.map(|value| {
!value.iconic_motifs.is_empty()
|| !value.institutions_or_artifacts.is_empty()
|| !value.hard_rules.is_empty()
})
.unwrap_or(false),
anchor_content.key_relationships.is_some(),
anchor_content.hidden_lines.is_some(),
anchor_content.iconic_elements.is_some(),
]
.iter()
.filter(|value| **value)

View File

@@ -2548,26 +2548,24 @@ mod tests {
name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()),
};
let manual_prompt = build_custom_world_scene_image_prompt(
SceneImagePromptParams {
profile: SceneImagePromptProfile {
name: profile_input.name.as_deref().unwrap_or_default(),
subtitle: profile_input.subtitle.as_deref().unwrap_or_default(),
tone: profile_input.tone.as_deref().unwrap_or_default(),
player_goal: profile_input.player_goal.as_deref().unwrap_or_default(),
summary: profile_input.summary.as_deref().unwrap_or_default(),
setting_text: profile_input.setting_text.as_deref().unwrap_or_default(),
},
landmark: SceneImagePromptLandmark {
name: landmark.name.as_deref().unwrap_or_default(),
description: landmark.description.as_deref().unwrap_or_default(),
},
user_prompt,
has_reference_image: false,
fallback_landmark_name: Some("礁石神殿"),
fallback_world_name: "雾海群岛",
let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams {
profile: SceneImagePromptProfile {
name: profile_input.name.as_deref().unwrap_or_default(),
subtitle: profile_input.subtitle.as_deref().unwrap_or_default(),
tone: profile_input.tone.as_deref().unwrap_or_default(),
player_goal: profile_input.player_goal.as_deref().unwrap_or_default(),
summary: profile_input.summary.as_deref().unwrap_or_default(),
setting_text: profile_input.setting_text.as_deref().unwrap_or_default(),
},
);
landmark: SceneImagePromptLandmark {
name: landmark.name.as_deref().unwrap_or_default(),
description: landmark.description.as_deref().unwrap_or_default(),
},
user_prompt,
has_reference_image: false,
fallback_landmark_name: Some("礁石神殿"),
fallback_world_name: "雾海群岛",
});
let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),

View File

@@ -933,7 +933,7 @@ fn build_scene_act_blueprint_from_landmark(
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("");
.map(ToOwned::to_owned);
let opposite_npc_id = scene_npc_names.first().cloned().unwrap_or_default();
let event_description = act_events
.get(act_index)
@@ -944,13 +944,20 @@ fn build_scene_act_blueprint_from_landmark(
.unwrap_or_else(|| {
build_default_act_event_description(scene_summary, opposite_npc_id.as_str(), act_index)
});
// 缺失时保留空值,让后续生图前校验暴露底稿质量问题。
let background_prompt = prompt.unwrap_or_else(|| {
build_default_act_background_prompt(
scene_summary,
opposite_npc_id.as_str(),
event_description.as_str(),
act_index,
)
});
json!({
"id": format!("{}-act-{}", scene_id, act_index + 1),
"sceneId": scene_id,
"title": act_title,
"summary": scene_summary,
"backgroundPromptText": prompt,
"backgroundPromptText": background_prompt,
"encounterNpcIds": scene_npc_names,
"primaryNpcId": opposite_npc_id,
"oppositeNpcId": opposite_npc_id,
@@ -982,11 +989,46 @@ fn build_default_act_event_description(
} else {
scene_summary.trim()
};
match act_index {
0 => format!(
"第1幕中{}先露出与{}有关的异常线索,玩家必须确认局势入口。",
role_text, scene_text
),
1 => format!(
"第2幕中{}的立场或阻碍让{}升级,玩家必须在压力下作出判断。",
role_text, scene_text
),
_ => format!(
"第3幕中{}{}推向高潮,玩家必须面对关键抉择或直接后果。",
role_text, scene_text
),
}
}
fn build_default_act_background_prompt(
scene_summary: &str,
opposite_npc_id: &str,
event_description: &str,
act_index: usize,
) -> String {
let role_text = if opposite_npc_id.trim().is_empty() {
"当前场景关键角色"
} else {
opposite_npc_id.trim()
};
let scene_text = if scene_summary.trim().is_empty() {
"场景内的主线压力"
} else {
scene_summary.trim()
};
let phase_text = match act_index {
0 => "铺垫阶段",
1 => "冲突升级阶段",
_ => "高潮阶段",
};
// 中文注释:幕背景默认值直接吃同幕事件和角色,避免前端再拼规则说明句。
format!(
"{}幕中,玩家与{}正面接触,围绕{}处理一件会改变局势走向的事件",
act_index + 1,
role_text,
scene_text,
"{scene_text}{phase_text}画面,{role_text}与玩家隔着可站立空间形成对峙,环境里保留“{event_description}”的冲突痕迹与清晰氛围"
)
}
@@ -1080,11 +1122,16 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
JsonValue::Array(
(0..3)
.map(|index| {
JsonValue::String(format!(
"{}{}幕,{},画面保留玩家站位、近景可交互物件与远景世界压力。",
camp_name,
index + 1,
camp_description,
let event_description = build_default_act_event_description(
camp_description.as_str(),
"开局关键角色",
index,
);
JsonValue::String(build_default_act_background_prompt(
camp_description.as_str(),
"开局关键角色",
event_description.as_str(),
index,
))
})
.collect(),
@@ -1382,17 +1429,12 @@ fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
.unwrap_or_else(|| "当前幕推动场景内的主线压力。".to_string());
object.insert("title".to_string(), JsonValue::String(title.clone()));
object.insert("summary".to_string(), JsonValue::String(summary.clone()));
let background_prompt = object
let raw_background_prompt = object
.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_default();
object.insert(
"backgroundPromptText".to_string(),
JsonValue::String(background_prompt),
);
.map(ToOwned::to_owned);
let encounter_npc_ids = object
.get("encounterNpcIds")
.and_then(JsonValue::as_array)
@@ -1434,6 +1476,18 @@ fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
.unwrap_or_else(|| {
build_default_act_event_description(summary.as_str(), opposite_npc_id.as_str(), index)
});
let background_prompt = raw_background_prompt.unwrap_or_else(|| {
build_default_act_background_prompt(
summary.as_str(),
opposite_npc_id.as_str(),
event_description.as_str(),
index,
)
});
object.insert(
"backgroundPromptText".to_string(),
JsonValue::String(background_prompt),
);
object.insert(
"encounterNpcIds".to_string(),
JsonValue::Array(encounter_npc_ids),
@@ -1468,15 +1522,25 @@ fn build_fallback_scene_act() -> JsonValue {
}
fn build_fallback_scene_act_with_index(index: usize) -> JsonValue {
let event_description = build_default_act_event_description(
"玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
"",
index,
);
json!({
"id": format!("scene-act-{}", index + 1),
"title": if index == 0 { "开场场景幕".to_string() } else { format!("{}", index + 1) },
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
"backgroundPromptText": "",
"backgroundPromptText": build_default_act_background_prompt(
"玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
"",
event_description.as_str(),
index,
),
"encounterNpcIds": [],
"primaryNpcId": "",
"oppositeNpcId": "",
"eventDescription": build_default_act_event_description("玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。", "", index),
"eventDescription": event_description,
})
}
@@ -1489,12 +1553,21 @@ fn derive_world_name(
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_object)
.and_then(|entry| entry.get("hook"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_object)
.and_then(|entry| entry.get("hook"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
})
.unwrap_or_else(|| "未命名世界草稿".to_string())
}
@@ -1508,12 +1581,21 @@ fn derive_world_hook(
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_object)
.and_then(|entry| entry.get("hook"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_object)
.and_then(|entry| entry.get("hook"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
})
.unwrap_or_else(|| {
"这个世界正被一条持续扩大的主线危机推向失衡,而玩家会被卷入其中心。".to_string()
@@ -1529,28 +1611,37 @@ fn derive_player_premise(
session
.anchor_content
.get("playerEntryPoint")
.and_then(JsonValue::as_object)
.map(|entry| {
let identity = entry
.get("openingIdentity")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
let problem = entry
.get("openingProblem")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
let motivation = entry
.get("entryMotivation")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
[identity, problem, motivation]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
session
.anchor_content
.get("playerEntryPoint")
.and_then(JsonValue::as_object)
.map(|entry| {
let identity = entry
.get("openingIdentity")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
let problem = entry
.get("openingProblem")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
let motivation = entry
.get("entryMotivation")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
[identity, problem, motivation]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("")
})
})
.filter(|value| !value.trim().is_empty())
})
@@ -1740,7 +1831,7 @@ mod tests {
}
#[test]
fn normalize_scene_act_keeps_missing_background_prompt_empty() {
fn normalize_scene_act_fills_missing_background_prompt_from_event() {
let act = normalize_scene_act_blueprint(
json!({
"title": "第1幕",
@@ -1749,7 +1840,15 @@ mod tests {
0,
);
assert_eq!(act.get("backgroundPromptText"), Some(&json!("")));
assert!(
act.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.is_some_and(|value| {
value.contains("铺垫阶段")
&& value.contains("玩家进入雾港码头")
&& value.contains("冲突痕迹")
})
);
assert!(
act.get("eventDescription")
.and_then(JsonValue::as_str)

View File

@@ -182,50 +182,28 @@ pub(crate) const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结
"replyText": "",
"progressPercent": 0,
"nextAnchorContent": {
"worldPromise": {
"hook": "",
"differentiator": "",
"desiredExperience": ""
},
"playerFantasy": {
"playerRole": "",
"corePursuit": "",
"fearOfLoss": ""
},
"themeBoundary": {
"toneKeywords": [],
"aestheticDirectives": [],
"forbiddenDirectives": []
},
"playerEntryPoint": {
"openingIdentity": "",
"openingProblem": "",
"entryMotivation": ""
},
"coreConflict": {
"surfaceConflicts": [],
"hiddenCrisis": "",
"firstTouchedConflict": ""
},
"keyRelationships": [
{
"pairs": "",
"relationshipType": "",
"secretOrCost": ""
}
],
"hiddenLines": {
"hiddenTruths": [],
"misdirectionHints": [],
"revealPacing": ""
},
"iconicElements": {
"iconicMotifs": [],
"institutionsOrArtifacts": [],
"hardRules": []
}
"worldPromise": "",
"playerFantasy": "",
"themeBoundary": "",
"playerEntryPoint": "",
"coreConflict": "",
"keyRelationships": "",
"hiddenLines": "",
"iconicElements": ""
}
}"#;
}
nextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null不允许输出对象或数组。
请把每个锚点写成一段凝练中文:
- worldPromise 关注世界钩子、差异点、玩家体验。
- playerFantasy 关注玩家身份、核心追求、失去风险。
- themeBoundary 关注主题气质、美术方向、禁用方向。
- playerEntryPoint 关注开局身份、开局问题、行动动机。
- coreConflict 关注表层冲突、隐藏危机、首次触发点。
- keyRelationships 关注关键人物关系、关系类型、代价或秘密。
- hiddenLines 关注隐藏真相、误导线索、揭示节奏。
- iconicElements 关注标志意象、组织/物件、硬规则。
"#;
pub(crate) fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
format!(

View File

@@ -1,27 +1,13 @@
/// 自定义世界角色主图提示词脚本。
pub(crate) fn build_character_visual_prompt(
prompt_text: &str,
character_brief_text: Option<&str>,
) -> String {
let character_brief = [character_brief_text.unwrap_or_default(), prompt_text]
.into_iter()
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n");
build_master_prompt(character_brief.as_str())
/// 自定义世界角色主图提示词脚本。
pub(crate) fn build_character_visual_prompt(prompt_text: &str) -> String {
build_master_prompt(prompt_text.trim())
}
/// 角色主图被供应商内容审核拦截时使用的安全兜底提示词。
///
/// 这里刻意不继续携带角色姓名、作品名和长设定文本,避免把可疑专名原样送回上游导致连续失败。
pub(crate) fn build_fallback_moderation_safe_character_visual_prompt(
prompt_text: &str,
character_brief_text: Option<&str>,
) -> String {
let source = [character_brief_text.unwrap_or_default(), prompt_text].join(" ");
let archetype = resolve_original_role_archetype(source.as_str());
pub(crate) fn build_fallback_moderation_safe_character_visual_prompt(prompt_text: &str) -> String {
let archetype = resolve_original_role_archetype(prompt_text);
build_master_prompt(
[
@@ -61,11 +47,11 @@ fn resolve_original_role_archetype(source: &str) -> &'static str {
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
fn build_master_prompt(character_brief: &str) -> String {
[
"单人2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,细节精致,设计感足,适合后续制作 sprite sheet 动画。".to_string(),
"单人2D像素角色形象,头身比必须控制在 1.5 到 2 头身,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
"风格要求:横版像素角色,头身比必须控制在 1 到 1.5 头身。使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点。".to_string(),
"风格要求:横版像素角色,细节精致,设计感足。使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点。".to_string(),
"如果角色形象设定没有明确要求非人身体结构,默认优先使用人类或类人动作角色骨架。\
默认将角色形象设定作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上。".to_string(),
"角色形象设定:".to_string(),
@@ -103,8 +89,6 @@ pub(crate) fn build_character_visual_negative_prompt() -> String {
"文字",
"水印",
"UI 元素",
"软萌 Q版大头贴",
"儿童绘本风",
"厚涂插画感",
"低对比柔边",
]

View File

@@ -36,8 +36,8 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
"- camp 必须表示玩家开局时的落脚处,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应开局场景第 1/2/3 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 4090 个汉字内。".to_string(),
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应第 1/2/3 幕背景图画面内容描述;每条必须基于同序号 actEventDescriptions 和相关角色写出画面主体、站位空间、冲突痕迹与氛围,能直接交给生图模型,控制在 4090 个汉字内。".to_string(),
"- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。".to_string(),
"- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(),
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
@@ -199,9 +199,9 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
"- 每个地点只保留name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。".to_string(),
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 4090 个汉字内。".to_string(),
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须基于同序号 actEventDescriptions、当前地点和可出场角色直接写出画面主体、站位空间、冲突痕迹与氛围控制在 40 到 90 个汉字内。".to_string(),
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景玩家会在……”这类标题、摘要、规则句拼接格式必须像可直接交给生图模型的自然画面描述。".to_string(),
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- description 控制在 12 到 24 个汉字内。".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
@@ -258,6 +258,7 @@ pub(crate) fn build_custom_world_landmark_network_batch_prompt(
"要求:".to_string(),
"- 必须只补全本批场景name 必须与本批场景完全一致,不得增删改名。".to_string(),
"- sceneNpcNames 只能引用上方可用场景角色名单中的名字,每个地点 1 到 3 个。".to_string(),
"- sceneNpcNames 的第一位会成为每幕对面主角色;三幕事件和幕背景必须围绕这个角色的行动、阻碍、试探或求助展开。".to_string(),
"- connectedLandmarkNames 优先引用已知关键场景名称,每个地点 1 到 3 个。".to_string(),
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),

View File

@@ -47,13 +47,14 @@ use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
};
use std::convert::Infallible;
use tokio::time::sleep;
@@ -1639,7 +1640,10 @@ async fn generate_puzzle_image_candidates(
let mut items = Vec::with_capacity(generated.images.len());
for (index, image) in generated.images.into_iter().enumerate() {
let candidate_id = format!("{session_id}-candidate-{}", candidate_start_index + index + 1);
let candidate_id = format!(
"{session_id}-candidate-{}",
candidate_start_index + index + 1
);
let asset = persist_puzzle_generated_asset(
state,
owner_user_id,
@@ -1690,10 +1694,12 @@ async fn build_local_next_puzzle_run(
}))
})?;
if current_level.status != "cleared" {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "current level is not cleared",
})));
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "current level is not cleared",
})),
);
}
if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? {
@@ -1702,10 +1708,12 @@ async fn build_local_next_puzzle_run(
let source_session_id = payload.source_session_id.unwrap_or_default();
if source_session_id.trim().is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "sourceSessionId is required when gallery has no next puzzle work",
})));
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "sourceSessionId is required when gallery has no next puzzle work",
})),
);
}
let session = state
.spacetime_client()
@@ -1767,14 +1775,23 @@ async fn build_local_next_puzzle_run(
let candidate = updated_session
.draft
.as_ref()
.and_then(|draft| draft.candidates.iter().find(|candidate| !candidate.image_src.is_empty()))
.and_then(|draft| {
draft
.candidates
.iter()
.find(|candidate| !candidate.image_src.is_empty())
})
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "现场生成后没有可用候选图",
}))
})?;
Ok(build_next_run_from_candidate(run, &updated_session, candidate))
Ok(build_next_run_from_candidate(
run,
&updated_session,
candidate,
))
}
async fn resolve_gallery_next_puzzle_work(
@@ -1788,7 +1805,10 @@ async fn resolve_gallery_next_puzzle_work(
.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())
&& item
.cover_image_src
.as_ref()
.is_some_and(|value| !value.is_empty())
&& !run.played_profile_ids.contains(&item.profile_id)
}))
}
@@ -1836,7 +1856,9 @@ fn build_next_run_from_candidate(
.map(|draft| format!("{} · 候选 {}", draft.level_name, level_index))
.unwrap_or_else(|| format!("候选拼图 {level_index}")),
"当前草稿".to_string(),
draft.map(|draft| draft.theme_tags.clone()).unwrap_or_default(),
draft
.map(|draft| draft.theme_tags.clone())
.unwrap_or_default(),
Some(candidate.image_src.clone()),
)
}
@@ -1893,13 +1915,14 @@ fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord {
}
let pieces = (0..total)
.map(|index| {
let current = positions
.get(index as usize)
.cloned()
.unwrap_or(PuzzleCellPositionRecord {
row: index / grid_size,
col: index % grid_size,
});
let current =
positions
.get(index as usize)
.cloned()
.unwrap_or(PuzzleCellPositionRecord {
row: index / grid_size,
col: index % grid_size,
});
PuzzlePieceStateRecord {
piece_id: format!("piece-{index}"),
correct_row: index / grid_size,

View File

@@ -71,7 +71,7 @@ pub async fn stream_runtime_npc_chat_turn(
let llm_result =
generate_llm_npc_chat_turn(&state, &request_context, &payload, &npc_name).await;
let (mut body, npc_reply, suggestions) = match llm_result {
let (mut body, npc_reply, suggestions, function_suggestions, force_exit) = match llm_result {
Some(result) => result,
None => {
let npc_reply = build_deterministic_npc_reply(
@@ -79,11 +79,21 @@ pub async fn stream_runtime_npc_chat_turn(
player_message,
payload.npc_initiates_conversation,
);
let suggestions = if should_force_chat_exit(payload.chat_directive.as_ref()) {
let force_exit = should_force_chat_exit(payload.chat_directive.as_ref())
|| should_hostile_chat_breakoff_deterministically(
player_message,
payload.chat_directive.as_ref(),
);
let suggestions = if force_exit {
Vec::new()
} else {
build_deterministic_chat_suggestions(npc_name.as_str(), player_message)
};
let function_suggestions = if force_exit {
Vec::new()
} else {
build_fallback_function_suggestions(payload.chat_directive.as_ref())
};
let mut body = String::new();
append_sse_event(
&request_context,
@@ -91,7 +101,13 @@ pub async fn stream_runtime_npc_chat_turn(
"reply_delta",
&json!({ "text": npc_reply }),
)?;
(body, npc_reply, suggestions)
(
body,
npc_reply,
suggestions,
function_suggestions,
force_exit,
)
}
};
@@ -103,8 +119,9 @@ pub async fn stream_runtime_npc_chat_turn(
"affinityDelta": affinity_delta,
"affinityText": describe_affinity_shift(affinity_delta),
"suggestions": suggestions,
"functionSuggestions": function_suggestions,
"pendingQuestOffer": null,
"chatDirective": build_completion_directive(payload.chat_directive.as_ref()),
"chatDirective": build_completion_directive(payload.chat_directive.as_ref(), force_exit),
});
append_sse_event(&request_context, &mut body, "complete", &complete_payload)?;
@@ -117,7 +134,7 @@ async fn generate_llm_npc_chat_turn(
request_context: &RequestContext,
payload: &NpcChatTurnRequest,
npc_name: &str,
) -> Option<(String, String, Vec<String>)> {
) -> Option<(String, String, Vec<String>, Vec<Value>, bool)> {
let llm_client = state.llm_client()?;
let character = payload
.character
@@ -169,7 +186,7 @@ async fn generate_llm_npc_chat_turn(
});
if should_force_chat_exit(payload.chat_directive.as_ref()) {
return Some((body, npc_reply, Vec::new()));
return Some((body, npc_reply, Vec::new(), Vec::new(), true));
}
let suggestion_prompt =
@@ -180,15 +197,37 @@ async fn generate_llm_npc_chat_turn(
]);
suggestion_request.max_tokens = Some(200);
suggestion_request.enable_web_search = state.config.rpg_llm_web_search_enabled;
let suggestions = llm_client
let suggestion_text = llm_client
.request_text(suggestion_request)
.await
.ok()
.map(|response| parse_line_list_content(response.content.as_str(), 3))
.filter(|items| items.len() == 3)
.unwrap_or_else(|| build_fallback_npc_chat_suggestions(payload.player_message.as_str()));
.map(|response| response.content)
.unwrap_or_default();
let (mut suggestions, mut function_suggestions, should_end_chat) =
parse_npc_chat_suggestion_resolution(
suggestion_text.as_str(),
payload.chat_directive.as_ref(),
);
let force_exit = should_end_chat
|| should_hostile_chat_breakoff_deterministically(
payload.player_message.as_str(),
payload.chat_directive.as_ref(),
);
Some((body, npc_reply, suggestions))
if force_exit {
suggestions.clear();
function_suggestions.clear();
} else if suggestions.is_empty() {
suggestions = build_fallback_npc_chat_suggestions(payload.player_message.as_str());
}
Some((
body,
npc_reply,
suggestions,
function_suggestions,
force_exit,
))
}
fn build_deterministic_npc_reply(
@@ -206,12 +245,12 @@ fn build_deterministic_npc_reply(
fn build_deterministic_chat_suggestions(npc_name: &str, player_message: &str) -> Vec<String> {
// 建议只承载玩家可点选的行动意图,不在 UI 里额外塞说明文案。
vec![
format!("继续询问{npc_name}的近况"),
"追问这里发生了什么".to_string(),
format!("{npc_name},我想先听你说"),
"这件事哪里不对劲".to_string(),
if player_message.contains('帮') || player_message.contains('忙') {
"请对方说清需要什么帮助".to_string()
"先别绕,说清代价".to_string()
} else {
"换个轻松的话题".to_string()
"你是不是还瞒着我".to_string()
},
]
}
@@ -225,33 +264,164 @@ fn build_fallback_npc_chat_suggestions(player_message: &str) -> Vec<String> {
};
vec![
"你刚才那句是什么意思".to_string(),
"我愿意先听你说完".to_string(),
format!("这事和{topic}有关吗"),
"愿意再说清楚点吗".to_string(),
"别再避重就轻".to_string(),
]
}
fn build_completion_directive(chat_directive: Option<&Value>) -> Value {
fn build_fallback_function_suggestions(chat_directive: Option<&Value>) -> Vec<Value> {
read_function_options(chat_directive)
.into_iter()
.filter(|option| {
read_string_field(option, "functionId")
.as_deref()
.is_some_and(|function_id| function_id != "npc_chat")
})
.take(2)
.filter_map(|option| {
let function_id = read_string_field(option, "functionId")?;
let action_text = read_string_field(option, "actionText")?;
Some(json!({
"functionId": function_id,
"actionText": action_text,
}))
})
.collect()
}
fn build_completion_directive(chat_directive: Option<&Value>, force_exit: bool) -> Value {
let Some(directive) = chat_directive else {
return Value::Null;
};
let closing_mode = read_string_field(directive, "closingMode")
.filter(|value| value == "foreshadow_close")
.unwrap_or_else(|| "free".to_string());
let force_exit = closing_mode == "foreshadow_close"
let force_exit = force_exit
|| closing_mode == "foreshadow_close"
|| directive
.get("forceExitAfterTurn")
.and_then(Value::as_bool)
.unwrap_or(false);
let termination_reason = if force_exit {
read_string_field(directive, "terminationReason")
.filter(|value| value == "player_exit" || value == "hostile_breakoff")
.or_else(|| {
if is_hostile_model_chat(chat_directive) {
Some("hostile_breakoff".to_string())
} else {
None
}
})
} else {
None
};
json!({
"turnLimit": directive.get("turnLimit").cloned().unwrap_or(Value::Null),
"remainingTurns": directive.get("remainingTurns").cloned().unwrap_or(Value::Null),
"forceExit": force_exit,
"closingMode": closing_mode,
"closingMode": if force_exit { "foreshadow_close" } else { closing_mode.as_str() },
"terminationReason": termination_reason,
})
}
fn parse_npc_chat_suggestion_resolution(
text: &str,
chat_directive: Option<&Value>,
) -> (Vec<String>, Vec<Value>, bool) {
let normalized = text.trim();
if normalized.is_empty() {
return (
Vec::new(),
build_fallback_function_suggestions(chat_directive),
false,
);
}
if let Ok(value) = serde_json::from_str::<Value>(normalized) {
let should_end_chat = value
.get("shouldEndChat")
.and_then(Value::as_bool)
.unwrap_or(false)
&& is_hostile_model_chat(chat_directive);
let suggestions = value
.get("suggestions")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToOwned::to_owned)
.take(3)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let function_suggestions =
parse_function_suggestions(value.get("functionSuggestions"), chat_directive);
return (suggestions, function_suggestions, should_end_chat);
}
(
parse_line_list_content(normalized, 3),
build_fallback_function_suggestions(chat_directive),
false,
)
}
fn parse_function_suggestions(value: Option<&Value>, chat_directive: Option<&Value>) -> Vec<Value> {
let allowed_options = read_function_options(chat_directive);
let allowed_ids = allowed_options
.iter()
.filter_map(|item| read_string_field(item, "functionId"))
.collect::<Vec<_>>();
let mut used_ids: Vec<String> = Vec::new();
value
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|item| {
let function_id = read_string_field(item, "functionId")?;
if function_id == "npc_chat" {
return None;
}
if !allowed_ids.is_empty() && !allowed_ids.contains(&function_id) {
return None;
}
if used_ids.contains(&function_id) {
return None;
}
let fallback_text = allowed_options
.iter()
.find(|option| {
read_string_field(option, "functionId").as_deref() == Some(function_id.as_str())
})
.and_then(|option| read_string_field(option, "actionText"));
let action_text = read_string_field(item, "actionText")
.or(fallback_text)
.filter(|text| !text.trim().is_empty())?;
used_ids.push(function_id.clone());
Some(json!({
"functionId": function_id,
"actionText": action_text,
}))
})
.take(3)
.collect()
}
fn read_function_options(chat_directive: Option<&Value>) -> Vec<&Value> {
chat_directive
.and_then(|directive| directive.get("functionOptions"))
.and_then(Value::as_array)
.map(|items| items.iter().collect::<Vec<_>>())
.unwrap_or_default()
}
fn read_string_field(value: &Value, field: &str) -> Option<String> {
value
.get(field)
@@ -268,18 +438,61 @@ fn read_number_field(value: &Value, field: &str) -> Option<f64> {
.filter(|number| number.is_finite())
}
fn read_bool_field(value: &Value, field: &str) -> Option<bool> {
value.get(field).and_then(Value::as_bool)
}
fn should_force_chat_exit(chat_directive: Option<&Value>) -> bool {
let Some(directive) = chat_directive else {
return false;
};
read_string_field(directive, "closingMode").as_deref() == Some("foreshadow_close")
|| read_string_field(directive, "terminationReason").as_deref() == Some("player_exit")
|| directive
.get("forceExitAfterTurn")
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn is_hostile_model_chat(chat_directive: Option<&Value>) -> bool {
let Some(directive) = chat_directive else {
return false;
};
read_string_field(directive, "terminationMode").as_deref() == Some("hostile_model")
|| read_bool_field(directive, "isHostileChat").unwrap_or(false)
}
fn should_hostile_chat_breakoff_deterministically(
player_message: &str,
chat_directive: Option<&Value>,
) -> bool {
if !is_hostile_model_chat(chat_directive) {
return false;
}
let Some(directive) = chat_directive else {
return false;
};
if read_string_field(directive, "terminationReason").as_deref() == Some("player_exit") {
return true;
}
let hostile_break_words = [
"动手",
"开战",
"拔刀",
"",
"",
"闭嘴",
"少废话",
"别挡路",
];
count_keyword_matches(player_message, &hostile_break_words) > 0
}
fn normalize_required_text(value: &str) -> Option<String> {
let normalized = value.trim();
if normalized.is_empty() {

View File

@@ -6,10 +6,16 @@ pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演
- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。"#;
pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为玩家生成下一轮可直接点击的 3 条聊天续写候选
只输出纯文本,共 3 行,每行 1 条
不要加编号、项目符号、Markdown、JSON 或额外说明。
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。"#;
pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为 RPG NPC 聊天生成下一步候选,并判断敌对聊天是否已经收束
只输出 JSON不要输出 Markdown 或解释
JSON 结构:
{"shouldEndChat":false,"terminationReason":null,"suggestions":["温和共情台词","冷静追问台词","施压质疑台词"],"functionSuggestions":[{"functionId":"...","actionText":"玩家动作文本"}]}
- suggestions 是玩家下一轮可直接说出口的中文短句,每条 20 字以内;三条必须按顺序导向不同氛围和好感结果。
- suggestions 第 1 条温和共情,通常让气氛缓和、好感上升;第 2 条冷静追问或试探,通常保持中性但推进情报;第 3 条施压、质疑或立场冲突,通常让气氛变紧、好感下降或付出代价。
- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。
- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId不写规则说明。
- 非敌对聊天 shouldEndChat 必须为 false。
- 敌对聊天可以随时 shouldEndChat=true且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#;
#[derive(Debug)]
pub(crate) struct NpcChatTurnPromptInput<'a> {
@@ -71,6 +77,17 @@ pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<
let closing_mode = chat_directive.and_then(|record| read_string(record.get("closingMode")));
let is_limited_negative_affinity_chat =
limit_reason.as_deref() == Some("negative_affinity") && turn_limit > 0.0;
let is_hostile_model_chat = chat_directive
.and_then(|record| read_string(record.get("terminationMode")))
.as_deref()
== Some("hostile_model")
|| chat_directive
.and_then(|record| read_bool(record.get("isHostileChat")))
.unwrap_or(false);
let is_player_exit_turn = chat_directive
.and_then(|record| read_string(record.get("terminationReason")))
.as_deref()
== Some("player_exit");
let is_foreshadow_close_turn = closing_mode.as_deref() == Some("foreshadow_close")
|| chat_directive
.and_then(|record| read_bool(record.get("forceExitAfterTurn")))
@@ -142,6 +159,21 @@ pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<
} else {
None
},
if is_hostile_model_chat {
Some("当前是敌对或负好感聊天。对方不受固定回合限制,但随时可能不耐烦、结束谈话并把局势推向战斗或驱逐。".to_string())
} else {
None
},
if is_hostile_model_chat {
Some("敌对角色更偏好短促、戒备、带威胁的回应;如果玩家逼问、挑衅、退场或话题触到底线,回复应自然收束到对峙前一刻。".to_string())
} else {
None
},
if is_player_exit_turn {
Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string())
} else {
None
},
if is_limited_negative_affinity_chat {
Some(format!(
"在你回复完这一轮之后,还剩 {} 轮可以继续聊。",
@@ -205,6 +237,22 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
payload.dialogue
};
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
let chat_directive = payload.chat_directive.and_then(as_record);
let is_hostile_model_chat = chat_directive
.and_then(|record| read_string(record.get("terminationMode")))
.as_deref()
== Some("hostile_model")
|| chat_directive
.and_then(|record| read_bool(record.get("isHostileChat")))
.unwrap_or(false);
let is_player_exit_turn = chat_directive
.and_then(|record| read_string(record.get("terminationReason")))
.as_deref()
== Some("player_exit");
let function_options_block = chat_directive
.and_then(|record| record.get("functionOptions"))
.map(describe_function_options)
.filter(|text| !text.trim().is_empty());
[
Some(build_npc_dialogue_prompt_base(payload)),
@@ -213,11 +261,22 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
encounter.npc_name.as_str(),
)),
combat_context_block,
function_options_block,
Some(format!("玩家刚刚说:{}", payload.player_message)),
Some(format!("NPC 刚刚回复:{npc_reply}")),
Some("请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。".to_string()),
Some("每条都必须像玩家台词,不能写成行为描述、语气说明或策略建议".to_string()),
Some("每条都必须控制在 20 个字以内,不要加序号、引号、括号或解释。".to_string()),
if is_hostile_model_chat {
Some("这是敌对或负好感聊天。你需要判断这轮是否应该结束聊天;敌对角色更偏好随时终止并转入对峙".to_string())
} else {
Some("这是非敌对聊天shouldEndChat 必须为 false。".to_string())
},
if is_player_exit_turn {
Some("玩家已经选择结束聊天shouldEndChat 必须为 trueterminationReason 必须为 player_exit。".to_string())
} else {
None
},
Some("suggestions 必须按顺序生成三种明显不同的玩家台词:温和共情、冷静追问或试探、施压质疑;不要给出同一种态度的近义句。".to_string()),
Some("functionSuggestions 从 functionOptions 中挑可触发动作并改写 actionText。".to_string()),
Some("只输出 JSON{\"shouldEndChat\":false,\"terminationReason\":null,\"suggestions\":[\"...\"],\"functionSuggestions\":[{\"functionId\":\"...\",\"actionText\":\"...\"}]}".to_string()),
]
.into_iter()
.flatten()
@@ -226,6 +285,38 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
.join("\n\n")
}
fn describe_function_options(value: &Value) -> String {
let lines = value
.as_array()
.map(|items| {
items
.iter()
.take(8)
.filter_map(|item| {
let record = as_record(item)?;
let function_id = read_string(record.get("functionId"))?;
let action_text = read_string(record.get("actionText"))?;
let detail_text = read_string(record.get("detailText"));
let action = read_string(record.get("action"));
Some(format!(
"- functionId: {function_id}; actionText: {action_text}; action: {}; detail: {}",
action.unwrap_or_else(|| "unknown".to_string()),
detail_text.unwrap_or_else(|| "".to_string()),
))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if lines.is_empty() {
return String::new();
}
let mut result = vec!["当前聊天中可改写为动作候选的 functionOptions".to_string()];
result.extend(lines);
result.join("\n")
}
fn build_npc_dialogue_prompt_base(payload: &NpcChatTurnPromptInput<'_>) -> String {
let encounter = describe_encounter(payload.encounter);

View File

@@ -7,15 +7,18 @@ use axum::{
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord,
RuntimeProfileRechargeProductRecord, RuntimeReferralInviteCenterRecord,
RuntimeReferralRedeemRecord,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
ProfileDashboardSummaryResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse,
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileReferralInviteCodeResponse,
};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
@@ -146,6 +149,54 @@ pub async fn create_profile_recharge_order(
))
}
pub async fn get_profile_referral_invite_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_referral_invite_center(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_referral_invite_center_response(record),
))
}
pub async fn redeem_profile_referral_invite_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RedeemProfileReferralInviteCodeRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.redeem_profile_referral_invite_code(user_id, payload.invite_code, updated_at_micros as i64)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_redeem_profile_referral_invite_code_response(record),
))
}
pub async fn get_profile_play_stats(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -284,6 +335,36 @@ fn build_profile_recharge_order_response(
}
}
fn build_profile_referral_invite_center_response(
record: RuntimeReferralInviteCenterRecord,
) -> ProfileReferralInviteCenterResponse {
ProfileReferralInviteCenterResponse {
invite_code: record.invite_code,
invite_link_path: record.invite_link_path,
invited_count: record.invited_count,
rewarded_invite_count: record.rewarded_invite_count,
today_inviter_reward_count: record.today_inviter_reward_count,
today_inviter_reward_remaining: record.today_inviter_reward_remaining,
reward_points: record.reward_points,
has_redeemed_code: record.has_redeemed_code,
bound_inviter_user_id: record.bound_inviter_user_id,
bound_at: record.bound_at,
updated_at: record.updated_at,
}
}
fn build_redeem_profile_referral_invite_code_response(
record: RuntimeReferralRedeemRecord,
) -> RedeemProfileReferralInviteCodeResponse {
RedeemProfileReferralInviteCodeResponse {
center: build_profile_referral_invite_center_response(record.center),
invitee_reward_granted: record.invitee_reward_granted,
inviter_reward_granted: record.inviter_reward_granted,
invitee_balance_after: record.invitee_balance_after,
inviter_balance_after: record.inviter_balance_after,
}
}
#[cfg(test)]
mod tests {
use axum::{
@@ -391,6 +472,43 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_referral_invite_center_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/referrals/invite-center")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_referral_redeem_code_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/referrals/redeem-code")
.header("content-type", "application/json")
.body(Body::from(r#"{"inviteCode":"SY12345678"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
@@ -479,16 +597,7 @@ mod tests {
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_profile_user".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
state
AppState::new(AppConfig::default()).expect("state should build")
}
fn issue_access_token(state: &AppState) -> String {