diff --git a/game/prompts.py b/game/prompts.py index ab5c857..0d89897 100644 --- a/game/prompts.py +++ b/game/prompts.py @@ -40,6 +40,16 @@ def build_system_prompt() -> str: def build_user_prompt(context: dict[str, Any]) -> str: story_context = context.get("story_context", {}) allowed_clues = story_context.get("allowed_clues", []) + players = context.get("players", []) + if not isinstance(players, list): + players = [] + player_names = [] + for row in players: + if not isinstance(row, dict): + continue + name = str(row.get("username") or row.get("name") or row.get("player_id") or "").strip() + if name: + player_names.append(name) clue_rule = "关键线索只可从允许列表中新增" if allowed_clues == "ANY_STRING": clue_rule = "线索字段可新增任意短文本,但必须与当前游戏卡世界观一致" @@ -47,7 +57,9 @@ def build_user_prompt(context: dict[str, Any]) -> str: payload = { "story_context": story_context, "game_state": context.get("game_state", {}), - "players": context.get("players", []), + "players": players, + "player_count": len(players), + "player_usernames": player_names, "merged_actions": context.get("merged_actions", {}), "schema": SCHEMA_DESCRIPTION, "constraints": [ @@ -60,7 +72,8 @@ def build_user_prompt(context: dict[str, Any]) -> str: "如果行动描述是“使用/消耗/投掷/服用/注射某道具”,且结果生效,请在 inventory_remove 删除该道具", "自定义卡会按 content_pack.end_conditions 由服务端判定结局", "choices 最多 4 条", - "private_player_updates 可为空", + "private_player_updates 需要覆盖所有玩家,每个 player_id 至少 1 条 message", + "private_player_updates 中的 player_id 必须来自 players 列表", "如果故事未满足结局条件,应设置 should_end_game=false", ], } diff --git a/game/room_manager.py b/game/room_manager.py index 5f60f5d..e088879 100644 --- a/game/room_manager.py +++ b/game/room_manager.py @@ -79,6 +79,48 @@ class RoomManager: return f"{text[:417]}..." return text + def _ensure_private_updates_for_all_players( + self, + ai_payload: dict[str, Any], + players: list[dict[str, Any]], + actions: list[PlayerAction], + ) -> None: + updates_raw = ai_payload.get("private_player_updates", []) + updates = updates_raw if isinstance(updates_raw, list) else [] + + by_player: dict[str, str] = {} + for row in updates: + if not isinstance(row, dict): + continue + player_id = str(row.get("player_id", "")).strip() + message = " ".join(str(row.get("message", "")).strip().split()) + if not player_id or not message: + continue + if player_id not in by_player: + by_player[player_id] = message[:220] + + action_by_player: dict[str, PlayerAction] = {row.player_id: row for row in actions} + for player in players: + player_id = str(player.get("player_id", "")).strip() + if not player_id: + continue + if player_id in by_player: + continue + action = action_by_player.get(player_id) + player_name = str(player.get("name") or player_id).strip() + if action: + action_text = " ".join(str(action.content or "").strip().split()) + if len(action_text) > 56: + action_text = f"{action_text[:56]}..." + by_player[player_id] = f"{player_name},你的行动“{action_text}”已计入结算,请根据新线索继续推进。" + else: + by_player[player_id] = f"{player_name},本轮未记录到你的行动,下一轮请尽快提交你的选择。" + + ai_payload["private_player_updates"] = [ + {"player_id": player_id, "message": message} + for player_id, message in by_player.items() + ] + def _ensure_unique_name(self, name: str, existing_names: set[str]) -> str: if name not in existing_names: return name @@ -473,11 +515,23 @@ class RoomManager: try: merge_result = self.action_resolver.merge_actions(actions, room_id, int(state.get("round_no", 0)) + 1) + user_cache: dict[str, dict[str, Any]] = {} + for player in players: + user_id = str(player.get("user_id") or "") + if not user_id or user_id in user_cache: + continue + user_cache[user_id] = self.persistence.get_user_by_id(user_id) or {} prompt_context = { "story_context": self.story_engine.story_context(state.get("scene_id", "lobby"), state), "game_state": state, "players": [ - {"player_id": player["player_id"], "name": player["name"], "is_online": player["is_online"]} + { + "player_id": player["player_id"], + "name": player["name"], + "username": user_cache.get(str(player.get("user_id") or ""), {}).get("username", ""), + "display_name": user_cache.get(str(player.get("user_id") or ""), {}).get("display_name", ""), + "is_online": player["is_online"], + } for player in players ], "merged_actions": merge_result, @@ -512,6 +566,7 @@ class RoomManager: if ai_payload is None: raise ValueError("ai_call_failed:empty_ai_payload") + self._ensure_private_updates_for_all_players(ai_payload, players, actions) next_state, round_result = self.story_engine.apply_turn(state, ai_payload, merge_result, players) next_status = "ended" if next_state.get("game_phase") == "ended" else "running" self.state_manager.set_room_state(room_id, next_status, next_state) diff --git a/services/deepseek_client.py b/services/deepseek_client.py index a344852..95de954 100644 --- a/services/deepseek_client.py +++ b/services/deepseek_client.py @@ -63,9 +63,33 @@ class DeepSeekClient: payload: dict[str, Any] = { "model": self.model, "messages": messages, - "temperature": temperature, - "max_tokens": max_tokens, } + payload["temperature"] = temperature + payload["max_tokens"] = max_tokens + if use_json_mode: + payload["response_format"] = {"type": "json_object"} + return payload + + def _build_payload( + self, + messages: list[dict[str, str]], + temperature: float, + max_tokens: int, + *, + use_json_mode: bool, + token_field: str, + include_temperature: bool, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "model": self.model, + "messages": messages, + } + if include_temperature: + payload["temperature"] = temperature + if token_field == "max_tokens": + payload["max_tokens"] = max_tokens + elif token_field == "max_completion_tokens": + payload["max_completion_tokens"] = max_tokens if use_json_mode: payload["response_format"] = {"type": "json_object"} return payload @@ -80,6 +104,50 @@ class DeepSeekClient: return True return "json schema" in lower and "support" in lower + def _extract_error_message(self, body: Any, raw_text: str) -> str: + if isinstance(body, dict): + error = body.get("error") + if isinstance(error, dict): + msg = error.get("message") + if isinstance(msg, str) and msg.strip(): + return msg.strip() + if isinstance(error, str) and error.strip(): + return error.strip() + msg = body.get("message") + if isinstance(msg, str) and msg.strip(): + return msg.strip() + text = str(raw_text or "").strip() + if not text: + return "" + if len(text) > 1000: + return text[:1000] + return text + + def _detect_token_field_switch(self, status_code: int, error_message: str, current_field: str) -> str | None: + if status_code not in {400, 404, 415, 422}: + return None + lower = str(error_message or "").lower() + if current_field == "max_tokens": + if "max_tokens" in lower and any(word in lower for word in ("unsupported", "unknown", "invalid", "not allowed", "unrecognized")): + return "max_completion_tokens" + if current_field == "max_completion_tokens": + if "max_completion_tokens" in lower and any(word in lower for word in ("unsupported", "unknown", "invalid", "not allowed", "unrecognized")): + return "max_tokens" + if current_field != "none": + if "maximum context length" in lower and "max_tokens" in lower: + return "none" + if "unexpected parameter" in lower and ("max_tokens" in lower or "max_completion_tokens" in lower): + return "none" + return None + + def _is_temperature_unsupported(self, status_code: int, error_message: str) -> bool: + if status_code not in {400, 404, 415, 422}: + return False + lower = str(error_message or "").lower() + if "temperature" not in lower: + return False + return any(word in lower for word in ("unsupported", "unknown", "invalid", "not allowed", "unrecognized")) + def _content_to_text(self, value: Any) -> str: if isinstance(value, str): return value.strip() @@ -119,14 +187,43 @@ class DeepSeekClient: content = "" if isinstance(message, dict): content = self._content_to_text(message.get("content")) + if not content: + tool_calls = message.get("tool_calls", []) + if isinstance(tool_calls, list) and tool_calls: + call = tool_calls[0] if isinstance(tool_calls[0], dict) else {} + func = call.get("function", {}) if isinstance(call, dict) else {} + if isinstance(func, dict): + content = self._content_to_text(func.get("arguments")) + if not content: + content = self._content_to_text(message.get("reasoning")) if not content: content = self._content_to_text(first.get("text")) + if not content and isinstance(first.get("delta"), dict): + content = self._content_to_text(first.get("delta", {}).get("content")) if content: return content output_text = self._content_to_text(body.get("output_text")) if output_text: return output_text + + output = body.get("output", []) + if isinstance(output, list): + blocks: list[str] = [] + for item in output: + if not isinstance(item, dict): + continue + content_rows = item.get("content", []) + if not isinstance(content_rows, list): + continue + for row in content_rows: + if isinstance(row, dict): + text = self._content_to_text(row.get("text")) + if text: + blocks.append(text) + merged = "\n".join(blocks).strip() + if merged: + return merged return "" def chat_json( @@ -146,12 +243,22 @@ class DeepSeekClient: last_detail = "" attempt = 0 use_json_mode = self.force_json_mode + token_field = "max_tokens" + include_temperature = True while attempt <= self.retries: try: + payload = self._build_payload( + messages, + temperature, + max_tokens, + use_json_mode=use_json_mode, + token_field=token_field, + include_temperature=include_temperature, + ) response = requests.post( url, headers=headers, - json=self._payload(messages, temperature, max_tokens, use_json_mode), + json=payload, timeout=(self.connect_timeout, self.read_timeout), ) raw_text = response.text or "" @@ -160,15 +267,28 @@ class DeepSeekClient: raise requests.HTTPError(last_error) if response.status_code >= 400: + body_error: Any = None + try: + body_error = response.json() + except (json.JSONDecodeError, ValueError): + body_error = None + error_message = self._extract_error_message(body_error, raw_text) if use_json_mode and self._is_response_format_unsupported(response.status_code, raw_text): use_json_mode = False continue + next_token_field = self._detect_token_field_switch(response.status_code, error_message, token_field) + if next_token_field is not None and next_token_field != token_field: + token_field = next_token_field + continue + if include_temperature and self._is_temperature_unsupported(response.status_code, error_message): + include_temperature = False + continue return { "ok": False, "error": f"http_{response.status_code}", "content": None, - "detail": "", - "raw": raw_text, + "detail": error_message, + "raw": body_error if isinstance(body_error, dict) else raw_text, } try: