1
This commit is contained in:
@@ -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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
56
server-rs/crates/api-server/server-rs/.data/auth-store.json
Normal file
56
server-rs/crates/api-server/server-rs/.data/auth-store.json
Normal 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": {}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("不参考任何现有"));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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版大头贴",
|
||||
"儿童绘本风",
|
||||
"厚涂插画感",
|
||||
"低对比柔边",
|
||||
]
|
||||
|
||||
@@ -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 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 40 到 90 个汉字内。".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 和相关角色写出画面主体、站位空间、冲突痕迹与氛围,能直接交给生图模型,控制在 40 到 90 个汉字内。".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 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内。".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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 必须为 true,terminationReason 必须为 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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user