import { v4 as uuidv4 } from "uuid";
import { generateCompletion, generateCompletionBatch, generateCompletionStream, mergeOptionsFromModelRef, stripMarkdownCodeFences, stripReasoningArtifacts, type GenerateOptions, type LLMMessage } from "./llm";
import type { ChatCompletionResponse } from "./llm";
import { StreamingSpeechParser } from "./streaming-speech-parser";
import {
  type GameState,
  type Player,
  type Role,
  type Phase,
  type ChatMessage,
  type Alignment,
  type DailySummaryVoteData,
  isWolfRole,
  ALL_MODELS,
  PLAYER_MODELS,
  PROJECT_MODELS,
  type ModelRef,
} from "@/types/game";
import { GAME_TEMPERATURE } from "./ai-config";
import { sampleModelRefs, type GeneratedCharacter } from "./character-generator";
import { aiLogger } from "./ai-logger";
import { getGeneratorModel, getSummaryModel } from "@/lib/api-keys";
import { PhaseManager } from "@/game/core/PhaseManager";
import type { PromptResult } from "@/game/core/types";
import { buildCachedSystemMessageFromParts } from "./prompt-utils";
import { parseLLMJson } from "./llm-json";
import { getI18n } from "@/i18n/translator";

function shuffleArray<T>(array: T[]): T[] {
  const shuffled = [...array];
  for (let i = shuffled.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  }
  return shuffled;
}

function getRandomModelRef(): ModelRef {
  const fallback = sampleModelRefs(1)[0];
  if (fallback) return fallback;
  if (PLAYER_MODELS.length === 0) {
    // Fallback to GENERATOR_MODEL if no models available
    return getModelRefForModel(getGeneratorModel());
  }
  const randomIndex = Math.floor(Math.random() * PLAYER_MODELS.length);
  return PLAYER_MODELS[randomIndex];
}

const phaseManager = new PhaseManager();

function getModelRefForModel(model: string): ModelRef {
  return (
    PROJECT_MODELS.find((ref) => ref.model === model) ??
    ALL_MODELS.find((ref) => ref.model === model) ??
    { provider: "zenmux" as const, model }
  );
}

function sanitizeModelArtifacts(text: string): string {
  const raw = String(text ?? "");
  if (!raw) return raw;

  return stripReasoningArtifacts(raw)
    .replace(/<\|begin▁of▁sentence\|>/g, "")
    .replace(/<\|end▁of▁sentence\|>/g, "")
    .replace(/<｜begin▁of▁sentence｜>/g, "")
    .replace(/<｜end▁of▁sentence｜>/g, "")
    .trim();
}

function sanitizeSeatMentions(text: string, players: Player[]): string {
  if (!text) return text;
  const totalSeats = players.length;
  if (!Number.isFinite(totalSeats) || totalSeats <= 0) return text;
  const { t } = getI18n();

  const formatSeatWithName = (
    raw: string,
    numStr: string,
    prefix: string,
    offset: number,
    fullText: string
  ) => {
    const n = Number.parseInt(numStr, 10);
    if (!Number.isFinite(n)) return raw;
    if (n < 1 || n > totalSeats) return t("gameMaster.invalidSeat");
    if (!prefix && fullText[offset - 1] === "@") return raw;
    const player = players.find((p) => p.seat === n - 1);
    if (!player?.displayName) return raw;
    const after = fullText.slice(offset + raw.length);
    const afterTrimmed = after.replace(/^\s+/, "");
    if (afterTrimmed.startsWith(player.displayName)) return raw;
    const label = t("mentions.playerLabel", { seat: n, name: player.displayName });
    return `${prefix}${label}`;
  };

  // Handle @12 / @12号
  let out = text.replace(/@(\d+)\s*号?/g, (m, numStr, offset, fullText) =>
    formatSeatWithName(m, numStr, "@", offset as number, fullText)
  );
  // Handle 12号
  out = out.replace(/(\d+)\s*号/g, (m, numStr, offset, fullText) =>
    formatSeatWithName(m, numStr, "", offset as number, fullText)
  );
  return out;
}

function resolvePhasePrompt(
  phase: Phase,
  state: GameState,
  player: Player,
  extras?: Record<string, unknown>
) {
  // Override state.phase to ensure correct prompt is returned
  // This is needed when calling prompts for a phase different from state.phase
  const overriddenState = state.phase === phase ? state : { ...state, phase };
  const prompt = phaseManager.getPrompt(phase, { state: overriddenState, extras }, player);
  if (!prompt) {
    throw new Error(`[wolfcha] Missing phase prompt for ${phase}`);
  }
  return prompt;
}

function buildMessagesForPrompt(
  prompt: PromptResult,
  useCache: boolean = true
): { messages: LLMMessage[]; systemMessage: LLMMessage } {
  const systemMessage = buildCachedSystemMessageFromParts(
    prompt.systemParts,
    prompt.system,
    useCache
  );

  return {
    systemMessage,
    messages: [
      systemMessage,
      { role: "user", content: prompt.user },
    ],
  };
}

export function createInitialGameState(): GameState {
  return {
    gameId: uuidv4(),
    phase: "LOBBY",
    day: 0,
    startTime: Date.now(),
    difficulty: "normal",
    players: [],
    events: [],
    messages: [],
    currentSpeakerSeat: null,
    nextSpeakerSeatOverride: null,
    daySpeechStartSeat: null,
    speechDirection: "clockwise",
    pkTargets: undefined,
    pkSource: undefined,
    badge: {
      holderSeat: null,
      candidates: [],
      signup: {},
      votes: {},
      allVotes: {},
      history: {},
      revoteCount: 0,
    },
    votes: {},
    voteReasons: {},
    lastVoteReasons: {},
    voteHistory: {},
    dailySummaries: {},
    dailySummaryFacts: {},
    dailySummaryVoteData: {},
    nightActions: {},
    roleAbilities: {
      witchHealUsed: false,
      witchPoisonUsed: false,
      hunterCanShoot: true,
      idiotRevealed: false,
      whiteWolfKingBoomUsed: false,
    },
    winner: null,
  };
}

export function getRoleConfiguration(playerCount: number): Role[] {
  const configs: Record<number, Role[]> = {
    8: ["Werewolf", "Werewolf", "Werewolf", "Seer", "Witch", "Hunter", "Villager", "Villager"],
    9: ["Werewolf", "Werewolf", "Werewolf", "Seer", "Witch", "Hunter", "Villager", "Villager", "Villager"],
    10: [
      "Werewolf",
      "Werewolf",
      "WhiteWolfKing",
      "Seer",
      "Witch",
      "Hunter",
      "Guard",
      "Villager",
      "Villager",
      "Villager",
    ],
    11: [
      "Werewolf",
      "Werewolf",
      "Werewolf",
      "WhiteWolfKing",
      "Seer",
      "Witch",
      "Hunter",
      "Guard",
      "Idiot",
      "Villager",
      "Villager",
    ],
    12: [
      "Werewolf",
      "Werewolf",
      "Werewolf",
      "WhiteWolfKing",
      "Seer",
      "Witch",
      "Hunter",
      "Guard",
      "Idiot",
      "Villager",
      "Villager",
      "Villager",
    ],
  };

  const roles = configs[playerCount] ?? configs[10];
  return roles.slice();
}

export function setupPlayers(
  characters: GeneratedCharacter[],
  humanSeat: number = 0,
  humanName: string = "",
  playerCount: number = 10,
  fixedRoles?: Role[],
  seedPlayerIds?: string[],
  modelRefs?: ModelRef[],
  aiSeatOrder?: number[],
  preferredRole?: Role
): Player[] {
  const { t } = getI18n();
  const totalPlayers = playerCount;
  const fallbackHumanName = t("common.you");
  const roles = getRoleConfiguration(totalPlayers);
  const assignedRoles = fixedRoles && fixedRoles.length === totalPlayers ? fixedRoles : shuffleArray(roles);

  // If the user chose a preferred role (and no dev fixedRoles), swap to ensure the human gets it
  if (
    preferredRole &&
    !(fixedRoles && fixedRoles.length === totalPlayers) &&
    humanSeat >= 0
  ) {
    const currentRoleAtHumanSeat = assignedRoles[humanSeat];
    if (currentRoleAtHumanSeat !== preferredRole) {
      const targetIndex = assignedRoles.findIndex(
        (r, i) => r === preferredRole && i !== humanSeat
      );
      if (targetIndex !== -1) {
        assignedRoles[targetIndex] = currentRoleAtHumanSeat;
        assignedRoles[humanSeat] = preferredRole;
      }
    }
  }

  const players: Player[] = [];

  const computeCharIndexForSeat = (() => {
    const aiSeats = Array.from({ length: totalPlayers }, (_, seat) => seat).filter(
      (seat) => seat !== humanSeat
    );

    if (
      Array.isArray(aiSeatOrder) &&
      aiSeatOrder.length === aiSeats.length &&
      new Set(aiSeatOrder).size === aiSeats.length &&
      aiSeatOrder.every((s) => aiSeats.includes(s))
    ) {
      const seatToCharIndex = new Map<number, number>();
      aiSeatOrder.forEach((seat, idx) => seatToCharIndex.set(seat, idx));
      return (seat: number) => seatToCharIndex.get(seat) ?? -1;
    }

    return (seat: number) => (seat > humanSeat ? seat - 1 : seat);
  })();

  const getPlayerIdForSeat = (seat: number) => {
    const id = Array.isArray(seedPlayerIds) ? seedPlayerIds[seat] : undefined;
    return typeof id === "string" && id.trim() ? id : uuidv4();
  };

  for (let seat = 0; seat < totalPlayers; seat++) {
    const role = assignedRoles[seat];
    const alignment: Alignment = isWolfRole(role) ? "wolf" : "village";
    const playerId = getPlayerIdForSeat(seat);

    if (seat === humanSeat) {
      players.push({
        playerId,
        seat,
        displayName: humanName.trim() || fallbackHumanName,
        avatarSeed: playerId,
        alive: true,
        role,
        alignment,
        isHuman: true,
      });
    } else {
      const charIndex = computeCharIndexForSeat(seat);
      const fallbackIndex = seat > humanSeat ? seat - 1 : seat;
      const safeCharIndex =
        Number.isFinite(charIndex) && charIndex >= 0 && charIndex < characters.length
          ? charIndex
          : Math.min(Math.max(0, fallbackIndex), Math.max(0, characters.length - 1));
      const character = characters[safeCharIndex];
      const modelRef = modelRefs?.[safeCharIndex] ?? getRandomModelRef();

      players.push({
        playerId,
        seat,
        displayName: character.displayName,
        avatarSeed: character.avatarSeed ?? playerId,
        alive: true,
        role,
        alignment,
        isHuman: false,
        agentProfile: {
          modelRef,
          persona: character.persona,
          playerMind: character.playerMind,
        },
      });
    }
  }

  return players;
}

export function addSystemMessage(
  state: GameState,
  content: string
): GameState {
  const { t } = getI18n();
  const message: ChatMessage = {
    id: uuidv4(),
    playerId: "system",
    playerName: t("speakers.host"),
    content,
    timestamp: Date.now(),
    day: state.day,
    phase: state.phase,
    isSystem: true,
  };

  return {
    ...state,
    messages: [...state.messages, message],
  };
}

export function addPlayerMessage(
  state: GameState,
  playerId: string,
  content: string,
  options?: { isLastWords?: boolean }
): GameState {
  const player = state.players.find((p) => p.playerId === playerId);
  if (!player) return state;

  const trimmedContent = content.trim();
  if (trimmedContent.length === 0) return state;

  // Auto-detect last words phase or use explicit flag
  const isLastWords = options?.isLastWords ?? state.phase === "DAY_LAST_WORDS";

  // Deduplication: prevent adding identical message from same player in same day/phase
  // Check recent messages (last 20) to avoid O(n) scan on large message arrays
  const recentMessages = state.messages.slice(-20);
  const isDuplicate = recentMessages.some(
    (m) =>
      m.playerId === playerId &&
      m.day === state.day &&
      m.phase === state.phase &&
      m.content === trimmedContent &&
      (isLastWords ? m.isLastWords === true : !m.isLastWords)
  );
  if (isDuplicate) {
    console.warn("[wolfcha] addPlayerMessage: duplicate message blocked", {
      playerId,
      day: state.day,
      phase: state.phase,
      contentPreview: trimmedContent.slice(0, 50),
    });
    return state;
  }

  const message: ChatMessage = {
    id: uuidv4(),
    playerId,
    playerName: player.displayName,
    content: trimmedContent,
    timestamp: Date.now(),
    day: state.day,
    phase: state.phase,
    ...(isLastWords && { isLastWords: true }),
  };

  return {
    ...state,
    messages: [...state.messages, message],
  };
}

export function transitionPhase(state: GameState, newPhase: Phase): GameState {
  // Clear currentSpeakerSeat when transitioning to night phases
  const isNightPhase = newPhase.startsWith("NIGHT_");
  const shouldClearSpeaker = isNightPhase || newPhase === "DAY_VOTE" || newPhase === "DAY_RESOLVE";

  return {
    ...state,
    phase: newPhase,
    ...(shouldClearSpeaker && { currentSpeakerSeat: null }),
  };
}

export function checkWinCondition(state: GameState): Alignment | null {
  const alivePlayers = state.players.filter((p) => p.alive);
  const aliveWolves = alivePlayers.filter((p) => p.alignment === "wolf");
  const aliveVillagers = alivePlayers.filter((p) => p.alignment === "village");

  if (aliveWolves.length === 0) {
    return "village";
  }

  if (aliveWolves.length >= aliveVillagers.length) {
    return "wolf";
  }

  return null;
}

export function killPlayer(state: GameState, seat: number): GameState {
  return {
    ...state,
    players: state.players.map((p) =>
      p.seat === seat ? { ...p, alive: false } : p
    ),
  };
}

export function getNextAliveSeat(
  state: GameState,
  currentSeat: number,
  excludeSheriff = false,
  direction: "clockwise" | "counterclockwise" = "clockwise"
): number | null {
  const sheriffSeat = state.badge.holderSeat;
  let alivePlayers = state.players.filter((p) => p.alive);

  // 如果需要排除警长（警长最后发言），则从候选列表中移除警长
  if (excludeSheriff && sheriffSeat !== null) {
    alivePlayers = alivePlayers.filter((p) => p.seat !== sheriffSeat);
  }

  if (alivePlayers.length === 0) return null;

  const sortedSeats = alivePlayers.map((p) => p.seat).sort((a, b) => a - b);
  if (sortedSeats.length === 0) return null;

  if (direction === "counterclockwise") {
    const prevSeat = [...sortedSeats].reverse().find((s) => s < currentSeat);
    return prevSeat ?? sortedSeats[sortedSeats.length - 1];
  }

  const nextSeat = sortedSeats.find((s) => s > currentSeat);
  return nextSeat ?? sortedSeats[0];
}

/**
 * 计算完整的发言顺序列表
 * @param state 游戏状态
 * @param startSeat 起始座位号（从该座位开始发言）
 * @param sheriffLast 警长是否最后发言（默认 true）
 * @returns 按发言顺序排列的座位号数组
 */
export function getSpeakingOrder(
  state: GameState,
  startSeat: number,
  sheriffLast = true
): number[] {
  const sheriffSeat = state.badge.holderSeat;
  const alivePlayers = state.players.filter((p) => p.alive);
  const aliveSeats = alivePlayers.map((p) => p.seat).sort((a, b) => a - b);

  if (aliveSeats.length === 0) return [];

  // 找到起始座位在排序列表中的索引
  const startIndex = aliveSeats.indexOf(startSeat);
  if (startIndex === -1) return aliveSeats;

  // 从起始座位开始，按顺时针顺序排列
  const order: number[] = [];
  for (let i = 0; i < aliveSeats.length; i++) {
    const seat = aliveSeats[(startIndex + i) % aliveSeats.length];
    // 如果警长最后发言，先跳过警长
    if (sheriffLast && seat === sheriffSeat) continue;
    order.push(seat);
  }

  // 如果警长最后发言且警长存活，将警长添加到最后
  if (sheriffLast && sheriffSeat !== null && aliveSeats.includes(sheriffSeat)) {
    order.push(sheriffSeat);
  }

  return order;
}

/**
 * 计算发言起始座位
 * @param state 游戏状态
 * @param options.deadSeat 死者座位（用于确定从死者下一位开始）
 * @param options.hasSheriff 是否有存活警长（默认自动检测）
 * @returns 起始座位号
 */
export function resolveSpeechStartSeat(
  state: GameState,
  options?: { deadSeat?: number; hasSheriff?: boolean }
): number | null {
  const alivePlayers = state.players.filter((p) => p.alive);
  const aliveSeats = alivePlayers.map((p) => p.seat).sort((a, b) => a - b);

  if (aliveSeats.length === 0) return null;

  const sheriffSeat = state.badge.holderSeat;
  const isSheriffAlive = options?.hasSheriff ??
    (sheriffSeat !== null && aliveSeats.includes(sheriffSeat));

  // 场上存在警长：从警长下一位开始
  if (isSheriffAlive && sheriffSeat !== null) {
    return getNextAliveSeat(state, sheriffSeat, true, "clockwise");
  }

  // 无警长但有死者：从死者下一位开始
  if (options?.deadSeat !== undefined) {
    return getNextAliveSeat(state, options.deadSeat, false, "clockwise");
  }

  // 默认：从最小座位号开始
  return aliveSeats[0];
}

export function tallyVotes(state: GameState): { seat: number; count: number } | null {
  const voteCounts: Record<number, number> = {};
  const sheriffSeat = state.badge.holderSeat;
  const aliveById = new Set(state.players.filter((p) => p.alive).map((p) => p.playerId));
  const aliveBySeat = new Set(state.players.filter((p) => p.alive).map((p) => p.seat));

  // 找到警长的 playerId
  const sheriffPlayer = sheriffSeat !== null
    ? state.players.find((p) => p.seat === sheriffSeat && p.alive)
    : null;
  const sheriffPlayerId = sheriffPlayer?.playerId;

  // 已翻牌白痴的投票不计入
  const revealedIdiotId = state.roleAbilities.idiotRevealed
    ? state.players.find((p) => p.role === "Idiot" && p.alive)?.playerId
    : undefined;

  for (const [voterId, targetSeat] of Object.entries(state.votes)) {
    if (!aliveById.has(voterId)) continue;
    if (!aliveBySeat.has(targetSeat)) continue;
    if (voterId === revealedIdiotId) continue; // 白痴翻牌后失去投票权
    // 警长的票计算为1.5票
    const voteWeight = voterId === sheriffPlayerId ? 1.5 : 1;
    voteCounts[targetSeat] = (voteCounts[targetSeat] || 0) + voteWeight;
  }

  let maxVotes = 0;
  let maxSeat: number | null = null;

  for (const [seat, count] of Object.entries(voteCounts)) {
    if (count > maxVotes) {
      maxVotes = count;
      maxSeat = parseInt(seat);
    }
  }

  // 平票判定：如果最高票并列，则无人被放逐
  if (maxVotes > 0) {
    const topSeats = Object.entries(voteCounts)
      .filter(([, c]) => c === maxVotes)
      .map(([s]) => parseInt(s));
    if (topSeats.length !== 1) return null;
  }

  if (maxSeat === null) return null;
  return { seat: maxSeat, count: maxVotes };
}

/** Extract structured vote_data from [VOTE_RESULT] in day messages. Preserves "who voted for whom" so it is not lost when context is trimmed. */
function extractVoteDataFromDayMessages(
  dayMessages: ChatMessage[],
  state: GameState
): DailySummaryVoteData | undefined {
  const { t } = getI18n();
  const badgeVoteTitle = t("badgePhase.voteDetailTitle");
  const dayVoteTitle = t("votePhase.voteDetailTitle");
  let sheriff: { winner: number; votes: Record<string, number[]> } | undefined;
  let execution: { eliminated: number; votes: Record<string, number[]> } | undefined;

  for (const m of dayMessages) {
    if (!m.isSystem || !m.content.startsWith("[VOTE_RESULT]")) continue;
    try {
      const json = m.content.slice("[VOTE_RESULT]".length);
      const data = JSON.parse(json) as { title?: string; results?: Array<{ targetSeat: number; voterSeats?: number[] }> };
      const results = data.results ?? [];
      const votes: Record<string, number[]> = {};
      for (const r of results) {
        const k = String(r.targetSeat);
        votes[k] = Array.isArray(r.voterSeats) ? r.voterSeats : [];
      }
      if (data.title === badgeVoteTitle && Object.keys(votes).length > 0) {
        const winner = state.badge.holderSeat ?? -1;
        sheriff = { winner, votes };
      } else if (data.title === dayVoteTitle && Object.keys(votes).length > 0) {
        const eliminated = state.dayHistory?.[state.day]?.executed?.seat ?? -1;
        execution = { eliminated, votes };
      }
    } catch {
      // skip malformed [VOTE_RESULT]
    }
  }

  if (!sheriff && !execution) return undefined;
  const out: DailySummaryVoteData = {};
  if (sheriff != null && sheriff.winner >= 0) out.sheriff_election = sheriff;
  if (execution != null && execution.eliminated >= 0) out.execution_vote = execution;
  return Object.keys(out).length > 0 ? out : undefined;
}

export async function generateDailySummary(
  state: GameState
): Promise<{ bullets: string[]; voteData?: DailySummaryVoteData }> {
  const { t } = getI18n();
  const startTime = Date.now();
  const summaryModel = getSummaryModel();
  const dayBreakText = t("system.dayBreak");
  const systemSpeaker = t("speakers.system");

  const dayStartIndex = (() => {
    for (let i = state.messages.length - 1; i >= 0; i--) {
      const m = state.messages[i];
      if (m.isSystem && m.content === dayBreakText) return i;
    }
    return 0;
  })();

  const dayMessages = state.messages.slice(dayStartIndex);
  const voteData = extractVoteDataFromDayMessages(dayMessages, state);

  const transcript = dayMessages
    .map((m) => {
      if (m.isSystem) return `${systemSpeaker}: ${m.content}`;
      const player = state.players.find((p) => p.playerId === m.playerId);
      const seatLabel = player ? t("mentions.seatLabel", { seat: player.seat + 1 }) : "";
      const nameLabel = player?.displayName || m.playerName;
      const speaker = seatLabel ? `${seatLabel} ${nameLabel}`.trim() : nameLabel;
      return `${speaker}: ${m.content}`;
    })
    .join("\n")
    .slice(0, 15000);

  const system = t("gameMaster.dailySummary.systemPrompt");
  const user = t("gameMaster.dailySummary.userPrompt", { day: state.day, transcript });

  const messages: LLMMessage[] = [
    { role: "system", content: system },
    { role: "user", content: user },
  ];

  const completion = await generateCompletionWithParseRetry(
    {
      model: summaryModel,
      messages,
      temperature: GAME_TEMPERATURE.SUMMARY,
      response_format: { type: "json_object" },
    },
    (cleaned) => {
      const obj = parseLLMJson<{ bullets?: unknown; summary?: unknown }>(cleaned);
      if (!obj || typeof obj !== "object" || Array.isArray(obj)) return parseFail();
      if (Array.isArray(obj.bullets) && obj.bullets.length > 0) {
        return parseOk({ bullets: obj.bullets.map(b => String(b)), voteData });
      }
      if (typeof obj.summary === "string" && obj.summary.trim()) {
        return parseOk({ bullets: [obj.summary.trim()], voteData });
      }
      return parseFail();
    },
    jsonRetryInstruction(JSON.stringify({ bullets: ["要点1", "要点2"] }))
  );

  await aiLogger.log({
    type: "daily_summary",
    request: {
      model: summaryModel,
      messages,
    },
    response: {
      content: completion.cleaned,
      raw: completion.result.content,
      rawResponse: JSON.stringify(completion.result.raw, null, 2),
      finishReason: completion.result.raw.choices?.[0]?.finish_reason,
      parsed: completion.parsed,
      duration: Date.now() - startTime,
    },
  });

  if (completion.parsed) return completion.parsed;

  // Fallback: use raw content without JSON wrapper
  const fallback = completion.result.content
    .replace(/```json\s*|\s*```/g, "")
    .trim();

  return { bullets: fallback ? [fallback] : [], voteData };
}

export async function* generateAISpeechStream(
  state: GameState,
  player: Player
): AsyncGenerator<string, void, unknown> {
  const { t } = getI18n();
  const prompt = resolvePhasePrompt(state.phase, state, player);
  const startTime = Date.now();
  const { messages } = buildMessagesForPrompt(prompt);

  let fullResponse = "";
  try {
    for await (const chunk of generateCompletionStream(mergeOptionsFromModelRef(player.agentProfile!.modelRef, {
      model: player.agentProfile!.modelRef.model,
      messages,
      temperature: GAME_TEMPERATURE.SPEECH,
    }))) {
      fullResponse += chunk;
      yield chunk;
    }

    const sanitizedSpeech = sanitizeSeatMentions(sanitizeModelArtifacts(fullResponse), state.players);
    await aiLogger.log({
      type: "speech",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        temperature: GAME_TEMPERATURE.SPEECH,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: sanitizedSpeech,
        raw: fullResponse,
        duration: Date.now() - startTime,
      },
    });
  } catch (error) {
    const sanitizedSpeech = sanitizeSeatMentions(sanitizeModelArtifacts(fullResponse), state.players);
    await aiLogger.log({
      type: "speech",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: { content: sanitizedSpeech, duration: Date.now() - startTime },
      error: String(error),
    });

    const raw = String(error);
    if (raw.includes("429") || raw.includes("limit_requests")) {
      if (!fullResponse.trim()) {
        yield t("gameMaster.tooManyRequests");
      }
      return;
    }

    throw error;
  }
}

export async function generateAISpeech(
  state: GameState,
  player: Player
): Promise<string> {
  let result = "";
  for await (const chunk of generateAISpeechStream(state, player)) {
    result += chunk;
  }
  return result;
}

export async function generateAISpeechSegments(
  state: GameState,
  player: Player
): Promise<string[]> {
  const { t } = getI18n();
  const prompt = resolvePhasePrompt(state.phase, state, player);
  const startTime = Date.now();
  const { messages } = buildMessagesForPrompt(prompt);

  const extractQuotedSegments = (text: string): string[] => {
    const start = text.indexOf("[");
    const end = text.lastIndexOf("]");
    const slice = start >= 0 && end > start ? text.slice(start, end + 1) : text;
    const out: string[] = [];
    const regex = /"(?:\\.|[^"\\])*"/g;
    let match: RegExpExecArray | null = null;
    while ((match = regex.exec(slice)) !== null) {
      const m = match[0];
      const matchEndIndex = match.index + m.length;

      let lookaheadIndex = matchEndIndex;
      while (lookaheadIndex < slice.length && /\s/.test(slice[lookaheadIndex] ?? "")) {
        lookaheadIndex++;
      }

      // If this string is immediately followed by ':' (after optional whitespace), it's a JSON object key.
      if (slice[lookaheadIndex] === ":") continue;

      try {
        const s = JSON.parse(m);
        if (typeof s === "string") {
          const cleaned = s.trim();
          if (cleaned) out.push(cleaned);
        }
      } catch {
        // ignore
      }
    }
    return out;
  };

  const extractObjectSegments = (text: string): string[] => {
    const objectMatch = text.match(/\{[\s\S]*\}/);
    if (!objectMatch) return [];
    try {
      const parsed = parseLLMJson<unknown>(objectMatch[0]);
      if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];

      const out: string[] = [];
      const allowedKeys = new Set(["speech", "message", "content", "text", "value"]);
      const reservedKeys = new Set(["analysis", "judgment", "judgement", "observation", "reasoning", "thought"]);
      for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
        const loweredKey = k.toLowerCase();
        if (reservedKeys.has(loweredKey)) continue;
        if (!allowedKeys.has(loweredKey)) continue;

        if (typeof v === "string") {
          const cleaned = v.trim();
          if (cleaned) out.push(cleaned);
          continue;
        }
        if (Array.isArray(v)) {
          for (const item of v) {
            if (typeof item === "string") {
              const cleaned = item.trim();
              if (cleaned) out.push(cleaned);
            }
          }
        }
      }
      return out;
    } catch {
      return [];
    }
  };

  try {
    const result = await generateCompletion(mergeOptionsFromModelRef(player.agentProfile!.modelRef, {
      model: player.agentProfile!.modelRef.model,
      messages,
      temperature: GAME_TEMPERATURE.SPEECH,
    }));

    const cleanedSpeech = sanitizeModelArtifacts(stripMarkdownCodeFences(result.content));
    const sanitizedSpeech = sanitizeSeatMentions(cleanedSpeech, state.players);

    await aiLogger.log({
      type: "speech",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: sanitizedSpeech,
        raw: result.content,
        rawResponse: JSON.stringify(result.raw, null, 2),
        finishReason: result.raw.choices?.[0]?.finish_reason,
        duration: Date.now() - startTime,
      },
    });

    // 尝试解析JSON数组
    try {
      const jsonMatch = sanitizedSpeech.match(/\[[\s\S]*\]/);
      if (jsonMatch) {
        const segments = parseLLMJson<unknown[]>(jsonMatch[0]);
        if (Array.isArray(segments) && segments.length > 0) {
          const normalized: string[] = [];
          for (const item of segments) {
            if (typeof item === "string") {
              // 情况1: ["话1", "话2"] - 字符串数组
              const cleaned = item.trim().replace(/^["']+|["']+$/g, "");
              if (cleaned) normalized.push(cleaned);
            } else if (item && typeof item === "object") {
              // 情况2: [{"speaker": "...", "message": "..."}] - 对象数组
              // 优先提取 content, message, text, value 等常见字段
              const obj = item as Record<string, unknown>;
              const text = obj.content || obj.message || obj.text || obj.value || obj.speech;
              if (typeof text === "string") {
                const cleaned = text.trim();
                if (cleaned) normalized.push(cleaned);
              }
            }
          }

          if (normalized.length > 0) {
            return normalized;
          }
        }
      }
    } catch {
      // JSON解析失败，按换行分割
    }

    const objectExtracted = extractObjectSegments(sanitizedSpeech);
    if (objectExtracted.length > 0) return objectExtracted;

    const extracted = extractQuotedSegments(sanitizedSpeech)
      .map((s) => s.trim().replace(/^['"]+|['"]+$/g, ""))
      .filter((s) => s.length > 0);
    if (extracted.length > 0) return extracted;

    // 降级处理：按换行或句号分割
    const fallbackSegments = sanitizedSpeech
      .replace(/[\[\]]/g, "")  // 只移除方括号，保留引号
      .split(/[。！？]+(?=\s|$)|\n+/)  // 按句号、感叹号、问号（后面跟空格或结尾）或换行分割
      .map(s => s.trim().replace(/^["']+|["']+$/g, ""))  // 移除首尾引号
      .filter(s => s.length > 2);  // 过滤掉长度小于等于2的片段

    if (fallbackSegments.length > 0) return fallbackSegments;

    const cleanedSingle = sanitizedSpeech
      .replace(/[\[\]]/g, "")
      .trim()
      .replace(/^["']+|["']+$/g, "")
      .trim();

    return cleanedSingle.length > 0 ? [cleanedSingle] : ["（……）"];
  } catch (error) {
    await aiLogger.log({
      type: "speech",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: { content: "", duration: Date.now() - startTime },
      error: String(error),
    });

    const raw = String(error);
    if (raw.includes("429") || raw.includes("limit_requests")) {
      return [t("gameMaster.tooManyRequests")];
    }

    throw error;
  }
}

export interface StreamingSpeechOptions {
  onSegmentReceived?: (segment: string, index: number) => void;
  onProgress?: (current: number) => void;
  onComplete?: (segments: string[]) => void;
  onError?: (error: string) => void;
}

/**
 * 流式生成 AI 发言段落
 * 实时输出发言内容，每完成一个段落就立即通知
 */
export async function generateAISpeechSegmentsStream(
  state: GameState,
  player: Player,
  options: StreamingSpeechOptions = {}
): Promise<string[]> {
  const { t } = getI18n();
  const prompt = resolvePhasePrompt(state.phase, state, player);
  const startTime = Date.now();
  const { messages } = buildMessagesForPrompt(prompt);

  // Track segments already emitted via streaming to prevent double-emit in fallback
  const emittedSegments = new Set<string>();
  let emittedCount = 0;

  const parser = new StreamingSpeechParser({
    onSegmentReceived: (segment, index) => {
      const sanitized = sanitizeSeatMentions(sanitizeModelArtifacts(segment), state.players);
      if (sanitized && !emittedSegments.has(sanitized)) {
        emittedSegments.add(sanitized);
        options.onSegmentReceived?.(sanitized, emittedCount++);
      }
    },
    onProgress: options.onProgress,
    onError: options.onError,
  });

  try {
    const stream = generateCompletionStream(mergeOptionsFromModelRef(player.agentProfile!.modelRef, {
      model: player.agentProfile!.modelRef.model,
      messages,
      temperature: GAME_TEMPERATURE.SPEECH,
    }));

    let accumulatedContent = "";
    let chunkCount = 0;

    for await (const chunk of stream) {
      chunkCount++;
      accumulatedContent += chunk;
      parser.processChunk(chunk);

      // 调试：每10个chunk输出一次
      if (chunkCount % 10 === 0) {
        console.log(`[streaming] chunks: ${chunkCount}, accumulated: ${accumulatedContent.length} chars, segments: ${parser.getSegmentCount()}`);
      }
    }

    console.log(`[streaming] done. total chunks: ${chunkCount}, segments emitted: ${parser.getSegmentCount()}, unique emitted: ${emittedSegments.size}`);

    // 结束解析
    const segments = parser.end();

    // 如果流式解析没有产生结果，回退到传统解析
    // 注意：只有当 emittedSegments 也为空时才进行回退，避免重复发射
    if (segments.length === 0 && emittedSegments.size === 0) {
      const cleanedSpeech = sanitizeModelArtifacts(stripMarkdownCodeFences(accumulatedContent));
      const sanitizedSpeech = sanitizeSeatMentions(cleanedSpeech, state.players);

      // 尝试解析 JSON 数组
      const jsonMatch = sanitizedSpeech.match(/\[[\s\S]*\]/);
      if (jsonMatch) {
        try {
          const parsed = parseLLMJson<unknown[]>(jsonMatch[0]);
          if (Array.isArray(parsed) && parsed.length > 0) {
            const normalized: string[] = [];
            for (const item of parsed) {
              if (typeof item === "string") {
                // 情况1: ["话1", "话2"] - 字符串数组
                const cleaned = item.trim().replace(/^["']+|["']+$/g, "");
                if (cleaned) normalized.push(cleaned);
              } else if (item && typeof item === "object") {
                // 情况2: [{"speaker": "...", "message": "..."}] - 对象数组
                const obj = item as Record<string, unknown>;
                const text = obj.content || obj.message || obj.text || obj.value || obj.speech;
                if (typeof text === "string") {
                  const cleaned = text.trim();
                  if (cleaned) normalized.push(cleaned);
                }
              }
            }

            if (normalized.length > 0) {
              // 通知回退解析的结果（只发射未发射过的）
              normalized.forEach((seg) => {
                if (!emittedSegments.has(seg)) {
                  emittedSegments.add(seg);
                  options.onSegmentReceived?.(seg, emittedCount++);
                }
              });
              options.onComplete?.(normalized);
              return normalized;
            }
          }
        } catch {
          // 继续尝试其他方法
        }
      }

      // 降级处理：按换行或句号分割
      const fallbackSegments = sanitizedSpeech
        .replace(/[\[\]]/g, "")
        .split(/[。！？]+(?=\s|$)|\n+/)
        .map((s) => s.trim().replace(/^["']+|["']+$/g, ""))
        .filter((s) => s.length > 2);

      if (fallbackSegments.length > 0) {
        fallbackSegments.forEach((seg) => {
          if (!emittedSegments.has(seg)) {
            emittedSegments.add(seg);
            options.onSegmentReceived?.(seg, emittedCount++);
          }
        });
        options.onComplete?.(fallbackSegments);
        return fallbackSegments;
      }

      const cleanedSingle = sanitizedSpeech
        .replace(/[\[\]]/g, "")
        .trim()
        .replace(/^["']+|["']+$/g, "")
        .trim();

      const result = cleanedSingle.length > 0 ? [cleanedSingle] : ["（……）"];
      result.forEach((seg) => {
        if (!emittedSegments.has(seg)) {
          emittedSegments.add(seg);
          options.onSegmentReceived?.(seg, emittedCount++);
        }
      });
      options.onComplete?.(result);
      return result;
    }

    // 如果流式已经发射了 segments 但 parser.end() 返回空，使用已发射的
    if (segments.length === 0 && emittedSegments.size > 0) {
      const emittedList = Array.from(emittedSegments);
      options.onComplete?.(emittedList);
      return emittedList;
    }

    // Sanitize all segments
    const sanitizedSegments = segments.map((s) =>
      sanitizeSeatMentions(sanitizeModelArtifacts(s), state.players)
    );

    await aiLogger.log({
      type: "speech",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: {
          playerId: player.playerId,
          displayName: player.displayName,
          seat: player.seat,
          role: player.role,
        },
      },
      response: {
        content: sanitizedSegments.join("\n"),
        raw: accumulatedContent,
        duration: Date.now() - startTime,
      },
    });

    options.onComplete?.(sanitizedSegments);
    return sanitizedSegments;
  } catch (error) {
    await aiLogger.log({
      type: "speech",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: {
          playerId: player.playerId,
          displayName: player.displayName,
          seat: player.seat,
          role: player.role,
        },
      },
      response: { content: "", duration: Date.now() - startTime },
      error: String(error),
    });

    const raw = String(error);
    if (raw.includes("429") || raw.includes("limit_requests")) {
      const result = [t("gameMaster.tooManyRequests")];
      options.onComplete?.(result);
      return result;
    }

    options.onError?.(String(error));
    throw error;
  }
}

export async function generateAIVote(
  state: GameState,
  player: Player
): Promise<{ seat: number; reason: string }> {
  const { t } = getI18n();
  const prompt = resolvePhasePrompt("DAY_VOTE", state, player);
  const eligibleSeats = state.pkSource === "vote" && state.pkTargets && state.pkTargets.length > 0
    ? new Set(state.pkTargets)
    : null;
  const alivePlayers = state.players.filter(
    (p) => p.alive && p.playerId !== player.playerId && (!eligibleSeats || eligibleSeats.has(p.seat))
  );
  const startTime = Date.now();
  const { messages } = buildMessagesForPrompt(prompt);

  try {
    const validSeats = alivePlayers.map((p) => p.seat);
    const parseSeatValue = (value: unknown): number | null => {
      const displaySeat =
        typeof value === "number"
          ? value
          : typeof value === "string" && /^\d+$/.test(value.trim())
            ? Number.parseInt(value.trim(), 10)
            : NaN;
      if (!Number.isFinite(displaySeat)) return null;
      const seat = displaySeat - 1;
      return validSeats.includes(seat) ? seat : null;
    };

    const completion = await generateCompletionWithParseRetry(
      mergeOptionsFromModelRef(player.agentProfile!.modelRef, {
        model: player.agentProfile!.modelRef.model,
        messages,
        temperature: GAME_TEMPERATURE.ACTION,
        response_format: { type: "json_object" },
      }),
      (cleaned) => {
        const parsed = parseLLMJson<{
          seat?: unknown;
          targetSeat?: unknown;
          target?: unknown;
          vote?: unknown;
          reason?: unknown;
        }>(cleaned);
        if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parseFail();

        const seat =
          parseSeatValue(parsed.seat) ??
          parseSeatValue(parsed.targetSeat) ??
          parseSeatValue(parsed.target) ??
          parseSeatValue(parsed.vote);
        if (seat === null) return parseFail();

        const reason = typeof parsed.reason === "string" ? parsed.reason.trim() : "";
        return parseOk({ seat, reason: reason || t("gameMaster.voteFallback.missingReason") });
      },
      jsonRetryInstruction(JSON.stringify({ seat: 3, reason: t("gameMaster.voteFallback.missingReason") }))
    );

    const parsedResult = completion.parsed ?? {
      seat: AI_VOTE_ABSTAIN,
      reason: alivePlayers.length === 0
        ? t("gameMaster.voteFallback.noTargets")
        : t("gameMaster.voteFallback.parseFailedAbstain"),
    };

    // Log with both raw and parsed data
    await aiLogger.log({
      type: "vote",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: completion.cleaned,
        raw: completion.result.content,
        rawResponse: JSON.stringify(completion.result.raw, null, 2),
        finishReason: completion.result.raw.choices?.[0]?.finish_reason,
        parsed: parsedResult,
        duration: Date.now() - startTime
      }
    });

    return parsedResult;
  } catch (error) {
    const fallbackResult = {
      seat: AI_VOTE_ABSTAIN,
      reason: alivePlayers.length === 0
        ? t("gameMaster.voteFallback.noTargets")
        : t("gameMaster.voteFallback.apiFailedAbstain"),
    };

    await aiLogger.log({
      type: "vote",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: "",
        parsed: fallbackResult,
        duration: Date.now() - startTime
      },
      error: String(error),
    });

    return fallbackResult;
  }
}

/** Sentinel for abstain when AI fails to vote or parse. Counting logic skips -1 via aliveBySeat.has(seat). */
export const AI_VOTE_ABSTAIN = -1;

/** Sentinel for abstain when AI fails to vote or parse. Counting logic skips -1 via aliveBySeat.has(seat). */
export const BADGE_VOTE_ABSTAIN = -1;

export const BADGE_TRANSFER_TORN = -1;

function parseDisplaySeatValue(value: unknown, validSeats: number[]): number | null {
  const displaySeat =
    typeof value === "number"
      ? value
      : typeof value === "string" && /^-?\d+$/.test(value.trim())
        ? Number.parseInt(value.trim(), 10)
        : NaN;

  if (!Number.isFinite(displaySeat)) return null;
  const seat = displaySeat - 1;
  return validSeats.includes(seat) ? seat : null;
}

function parseLLMDisplaySeat(raw: string, validSeats: number[], keys: string[] = ["seat", "targetSeat", "target", "vote"]): number | null {
  if (validSeats.length === 0) return null;

  const cleaned = stripMarkdownCodeFences(raw).trim();
  const parsed = parseLLMJson<unknown>(cleaned);

  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
    const record = parsed as Record<string, unknown>;
    for (const key of keys) {
      const seat = parseDisplaySeatValue(record[key], validSeats);
      if (seat !== null) return seat;
    }
  }

  return null;
}

function firstSeat(validSeats: number[]): number | undefined {
  return [...validSeats].sort((a, b) => a - b)[0];
}

type ParseAttempt<T> = { ok: true; value: T } | { ok: false };

type ParsedCompletion<T> = {
  result: { content: string; raw: ChatCompletionResponse };
  cleaned: string;
  parsed: T | null;
  attempts: number;
};

function parseOk<T>(value: T): ParseAttempt<T> {
  return { ok: true, value };
}

function parseFail(): ParseAttempt<never> {
  return { ok: false };
}

async function generateCompletionWithParseRetry<T>(
  options: GenerateOptions,
  parse: (cleaned: string) => ParseAttempt<T>,
  retryInstruction: string
): Promise<ParsedCompletion<T>> {
  let messages = options.messages;
  let last: ParsedCompletion<T> | null = null;

  for (let attempt = 1; attempt <= 2; attempt += 1) {
    const result = await generateCompletion({ ...options, messages });
    const cleaned = stripMarkdownCodeFences(result.content).trim();
    const parsed = parse(cleaned);

    last = {
      result,
      cleaned,
      parsed: parsed.ok ? parsed.value : null,
      attempts: attempt,
    };

    if (parsed.ok) return last;

    messages = [
      ...options.messages,
      { role: "assistant", content: result.content.slice(0, 4000) },
      { role: "user", content: retryInstruction },
    ];
  }

  if (!last) throw new Error("No LLM completion attempts were made");
  return last;
}

function jsonRetryInstruction(formatHint: string): string {
  return `上一轮输出无法按预期 JSON 结构解析。The previous output did not match the expected JSON schema. 请严格只返回 JSON / Return JSON only, no markdown, no explanation, no extra text. 预期结构 / Expected shape: ${formatHint}`;
}

/**
 * 使用 SUMMARY_MODEL 一次性判断所有玩家是否上警
 * 基于玩家的背景信息和第一晚的操作信息来决策
 */
export async function generateAIBadgeSignupBatch(
  state: GameState,
  players: Player[]
): Promise<Record<string, boolean>> {
  if (!players || players.length === 0) return {};

  const { t } = getI18n();
  const startTime = Date.now();

  // 构建所有玩家的信息摘要
  const playersInfo = players.map((player) => {
    const persona = player.agentProfile?.persona;
    const roleText = (() => {
      switch (player.role) {
        case "Werewolf": return t("promptUtils.roleText.werewolf");
        case "WhiteWolfKing": return t("promptUtils.roleText.whiteWolfKing");
        case "Seer": return t("promptUtils.roleText.seer");
        case "Witch": return t("promptUtils.roleText.witch");
        case "Hunter": return t("promptUtils.roleText.hunter");
        case "Guard": return t("promptUtils.roleText.guard");
        case "Idiot": return t("promptUtils.roleText.idiot");
        default: return t("promptUtils.roleText.villager");
      }
    })();

    // 获取玩家第一晚的行动信息
    let nightActionInfo = "";
    if (player.role === "Seer" && state.nightActions.seerHistory && state.nightActions.seerHistory.length > 0) {
      const lastCheck = state.nightActions.seerHistory[state.nightActions.seerHistory.length - 1];
      const targetPlayer = state.players.find((p) => p.seat === lastCheck.targetSeat);
      if (targetPlayer) {
        nightActionInfo = t("gameMaster.badgeSignup.seerChecked", {
          targetSeat: lastCheck.targetSeat + 1,
          targetName: targetPlayer.displayName,
          result: lastCheck.isWolf ? t("gameMaster.badgeSignup.werewolf") : t("gameMaster.badgeSignup.notWerewolf"),
        });
      }
    } else if (player.role === "Witch") {
      const actions: string[] = [];
      if (state.roleAbilities?.witchHealUsed) {
        actions.push(t("gameMaster.badgeSignup.witchHealed"));
      }
      if (state.roleAbilities?.witchPoisonUsed) {
        actions.push(t("gameMaster.badgeSignup.witchPoisoned"));
      }
      if (actions.length > 0) {
        nightActionInfo = actions.join(t("promptUtils.gameContext.listSeparator"));
      }
    } else if (player.role === "Guard" && state.nightActions.lastGuardTarget !== undefined) {
      const targetPlayer = state.players.find((p) => p.seat === state.nightActions.lastGuardTarget);
      if (targetPlayer) {
        nightActionInfo = t("gameMaster.badgeSignup.guardProtected", {
          targetSeat: state.nightActions.lastGuardTarget + 1,
          targetName: targetPlayer.displayName
        });
      }
    } else if (isWolfRole(player.role)) {
      nightActionInfo = t("gameMaster.badgeSignup.werewolfParticipated");
    }

    // 从 Persona 构建性格描述
    const personalityParts: string[] = [];
    if (persona?.mbti) personalityParts.push(persona.mbti);
    if (persona?.styleLabel) personalityParts.push(persona.styleLabel);
    if (persona?.socialHabit) personalityParts.push(persona.socialHabit);

    return {
      playerId: player.playerId,
      seat: player.seat + 1,
      name: player.displayName,
      role: roleText,
      personality: personalityParts.join("，") || "",
      nightAction: nightActionInfo,
    };
  });

  // 构建批量判断的 prompt
  // 使用 t.raw() 因为 prompt 包含字面的 {} 字符（JSON 示例），不应被解析为 ICU 占位符
  const systemPrompt = t.raw("gameMaster.badgeSignup.systemPrompt");
  const playerDescriptions = playersInfo.map((info) => {
    let desc = t("gameMaster.badgeSignup.playerDesc", {
      seat: info.seat,
      name: info.name,
      role: info.role,
      personality: info.personality || t("gameMaster.badgeSignup.noPersonality"),
    });
    if (info.nightAction) {
      desc += t("gameMaster.badgeSignup.nightActionSuffix", { action: info.nightAction });
    }
    return desc;
  }).join("\n");

  // userPrompt 也包含字面 {} 字符，需要手动替换占位符
  const userPromptTemplate = t.raw("gameMaster.badgeSignup.userPrompt");
  const userPrompt = userPromptTemplate
    .replace("{playerDescriptions}", playerDescriptions)
    .replace("{seatList}", playersInfo.map((p) => p.seat).join(", "));

  const messages: LLMMessage[] = [
    { role: "system", content: systemPrompt },
    { role: "user", content: userPrompt },
  ];

  const parsedByPlayer: Record<string, boolean> = {};

  try {
    const parseBadgeSignup = (cleaned: string): ParseAttempt<Record<string, boolean>> => {
      const parsed = parseLLMJson<Record<string, unknown>>(cleaned);
      if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parseFail();

      // 支持多种响应格式
      // 格式1: { "decisions": { "1": true, "2": false, ... } }
      // 格式2: { "1": true, "2": false, ... }
      // 格式3: { "signup": [1, 3, 5] } (seats that signup)
      let decisions: Record<string, boolean> = {};

      if (parsed.decisions && typeof parsed.decisions === "object") {
        decisions = parsed.decisions as Record<string, boolean>;
      } else if (parsed.signup && Array.isArray(parsed.signup)) {
        const signupSeats = new Set(parsed.signup.map(Number));
        for (const info of playersInfo) {
          decisions[String(info.seat)] = signupSeats.has(info.seat);
        }
      } else {
        for (const [key, value] of Object.entries(parsed)) {
          if (typeof value === "boolean" || value === 0 || value === 1) {
            decisions[key] = Boolean(value);
          }
        }
      }

      const parsedDecisions: Record<string, boolean> = {};
      for (const info of playersInfo) {
        const decision = decisions[String(info.seat)];
        parsedDecisions[info.playerId] = decision === true;
      }
      return parseOk(parsedDecisions);
    };

    const completion = await generateCompletionWithParseRetry(
      {
        model: getSummaryModel(),
        messages,
        temperature: GAME_TEMPERATURE.BADGE_SIGNUP,
        response_format: { type: "json_object" },
      },
      parseBadgeSignup,
      jsonRetryInstruction("{\"2\": true, \"3\": false}")
    );

    const duration = Date.now() - startTime;

    await aiLogger.log({
      type: "badge_signup",
      request: {
        model: getSummaryModel(),
        messages,
        player: { playerId: "batch", displayName: "All Players", seat: -1, role: "Villager" as Role },
      },
      response: {
        content: completion.cleaned,
        raw: completion.result.content,
        rawResponse: JSON.stringify(completion.result.raw, null, 2),
        finishReason: completion.result.raw.choices?.[0]?.finish_reason,
        parsed: completion.parsed,
        duration,
      },
    });

    if (completion.parsed) {
      Object.assign(parsedByPlayer, completion.parsed);
    } else {
      for (const player of players) {
        parsedByPlayer[player.playerId] = false;
      }
    }
  } catch (error) {
    const duration = Date.now() - startTime;
    await aiLogger.log({
      type: "badge_signup",
      request: {
        model: getSummaryModel(),
        messages,
        player: { playerId: "batch", displayName: "All Players", seat: -1, role: "Villager" as Role },
      },
      response: { content: "", duration },
      error: String(error),
    });

    // 出错时默认所有人不上警
    for (const player of players) {
      parsedByPlayer[player.playerId] = false;
    }
  }

  return parsedByPlayer;
}

export async function generateAIBadgeVote(
  state: GameState,
  player: Player
): Promise<number> {
  const prompt = resolvePhasePrompt("DAY_BADGE_ELECTION", state, player);
  const candidates = Array.isArray(state.badge?.candidates) ? state.badge.candidates : [];
  const alivePlayers = state.players
    .filter((p) => p.alive && p.playerId !== player.playerId)
    .filter((p) => (candidates.length > 0 ? candidates.includes(p.seat) : true));
  const startTime = Date.now();
  const { messages } = buildMessagesForPrompt(prompt);
  const validSeats = alivePlayers.map((p) => p.seat);

  if (validSeats.length === 0) return BADGE_VOTE_ABSTAIN;

  try {
    const completion = await generateCompletionWithParseRetry<number>(
      mergeOptionsFromModelRef(player.agentProfile!.modelRef, {
        model: player.agentProfile!.modelRef.model,
        messages,
        temperature: GAME_TEMPERATURE.ACTION,
        response_format: { type: "json_object" },
      }),
      (cleaned) => {
        const parsedSeat = parseLLMDisplaySeat(cleaned, validSeats, ["seat", "targetSeat", "target", "vote"]);
        return parsedSeat === null ? parseFail() : parseOk(parsedSeat);
      },
      jsonRetryInstruction(JSON.stringify({ seat: (firstSeat(validSeats) ?? 0) + 1 }))
    );
    const parsedSeat = completion.parsed ?? BADGE_VOTE_ABSTAIN;

    await aiLogger.log({
      type: "badge_vote",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: completion.cleaned,
        raw: completion.result.content,
        rawResponse: JSON.stringify(completion.result.raw, null, 2),
        finishReason: completion.result.raw.choices?.[0]?.finish_reason,
        parsed: { targetSeat: parsedSeat, attempts: completion.attempts },
        duration: Date.now() - startTime,
      },
    });

    return parsedSeat;
  } catch (error) {
    // Network/API error: treat as abstain so the phase does not get stuck
    console.warn("[wolfcha] generateAIBadgeVote failed, treating as abstain:", error);
    await aiLogger.log({
      type: "badge_vote",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages: [],
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: { content: "", duration: Date.now() - startTime },
      error: String(error),
    });
    return BADGE_VOTE_ABSTAIN;
  }
}

export async function generateBadgeTransfer(
  state: GameState,
  player: Player
): Promise<number> {
  const prompt = resolvePhasePrompt("BADGE_TRANSFER", state, player);
  const alivePlayers = state.players.filter(
    (p) => p.alive && p.playerId !== player.playerId
  );
  const confirmedWolfSeats = new Set(
    (state.nightActions.seerHistory || [])
      .filter((x) => x && x.isWolf)
      .map((x) => x.targetSeat)
  );
  const startTime = Date.now();
  const { messages } = buildMessagesForPrompt(prompt);
  const validSeats = alivePlayers.map((p) => p.seat);

  try {
    const pickSafeSeat = (): number => {
      if (player.alignment === "village" && player.role === "Seer" && confirmedWolfSeats.size > 0) {
        const safeSeat = firstSeat(validSeats.filter((s) => !confirmedWolfSeats.has(s)));
        return safeSeat ?? BADGE_TRANSFER_TORN;
      }

      return BADGE_TRANSFER_TORN;
    };

    const completion = await generateCompletionWithParseRetry(
      mergeOptionsFromModelRef(player.agentProfile!.modelRef, {
        model: player.agentProfile!.modelRef.model,
        messages,
        temperature: GAME_TEMPERATURE.ACTION,
        response_format: { type: "json_object" },
      }),
      (cleaned) => {
        const parsedTransfer = parseLLMJson<{ seat?: unknown; targetSeat?: unknown; target?: unknown; transfer?: unknown; action?: unknown }>(cleaned);
        if (!parsedTransfer || typeof parsedTransfer !== "object" || Array.isArray(parsedTransfer)) return parseFail();

        const action = String(parsedTransfer.action ?? "").toLowerCase();
        const rawSeat = parsedTransfer.seat ?? parsedTransfer.targetSeat ?? parsedTransfer.target ?? parsedTransfer.transfer;
        const wantsTear =
          action.includes("tear") ||
          action.includes("destroy") ||
          action.includes("撕") ||
          rawSeat === 0 ||
          rawSeat === "0";
        if (wantsTear) return parseOk(BADGE_TRANSFER_TORN);

        const parsedSeat = parseLLMDisplaySeat(cleaned, validSeats, ["seat", "targetSeat", "target", "transfer"]);
        if (parsedSeat === null) return parseFail();
        if (player.alignment === "village" && player.role === "Seer" && confirmedWolfSeats.has(parsedSeat)) {
          return parseOk(pickSafeSeat());
        }
        return parseOk(parsedSeat);
      },
      jsonRetryInstruction(JSON.stringify({ seat: 3 }))
    );

    const parsedSeat = completion.parsed ?? BADGE_TRANSFER_TORN;

    await aiLogger.log({
      type: "badge_transfer",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: completion.cleaned,
        raw: completion.result.content,
        rawResponse: JSON.stringify(completion.result.raw, null, 2),
        finishReason: completion.result.raw.choices?.[0]?.finish_reason,
        parsed: { targetSeat: parsedSeat },
        duration: Date.now() - startTime,
      }
    });

    return parsedSeat;
  } catch (error) {
    console.warn("[wolfcha] generateBadgeTransfer failed, tearing badge:", error);
    await aiLogger.log({
      type: "badge_transfer",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: "",
        parsed: { targetSeat: BADGE_TRANSFER_TORN },
        duration: Date.now() - startTime,
      },
      error: String(error),
    });
    return BADGE_TRANSFER_TORN;
  }
}

export async function generateSeerAction(
  state: GameState,
  player: Player
): Promise<number | undefined> {
  const prompt = resolvePhasePrompt("NIGHT_SEER_ACTION", state, player);
  const alivePlayers = state.players.filter(
    (p) => p.alive && p.playerId !== player.playerId
  );
  const startTime = Date.now();
  const { messages } = buildMessagesForPrompt(prompt);
  const validSeats = alivePlayers.map((p) => p.seat);

  try {
    const completion = await generateCompletionWithParseRetry(
      mergeOptionsFromModelRef(player.agentProfile!.modelRef, {
        model: player.agentProfile!.modelRef.model,
        messages,
        temperature: GAME_TEMPERATURE.ACTION,
        response_format: { type: "json_object" },
      }),
      (cleaned) => {
        const parsedSeat = parseLLMDisplaySeat(cleaned, validSeats, ["seat", "targetSeat", "target", "check"]);
        return parsedSeat === null ? parseFail() : parseOk(parsedSeat);
      },
      jsonRetryInstruction(JSON.stringify({ seat: 5 }))
    );
    const parsedSeat = completion.parsed ?? undefined;

    await aiLogger.log({
      type: "seer_action",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: completion.cleaned,
        raw: completion.result.content,
        rawResponse: JSON.stringify(completion.result.raw, null, 2),
        finishReason: completion.result.raw.choices?.[0]?.finish_reason,
        parsed: { targetSeat: parsedSeat },
        duration: Date.now() - startTime
      },
    });

    return parsedSeat;
  } catch (error) {
    console.warn("[wolfcha] generateSeerAction failed, skipping seer check:", error);
    await aiLogger.log({
      type: "seer_action",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: "",
        parsed: { targetSeat: undefined },
        duration: Date.now() - startTime,
      },
      error: String(error),
    });
    return undefined;
  }
}

export async function generateWolfAction(
  state: GameState,
  player: Player,
  existingVotes: Record<string, number> = {}
): Promise<number | undefined> {
  const prompt = resolvePhasePrompt("NIGHT_WOLF_ACTION", state, player, { existingVotes });
  const alivePlayers = state.players.filter((p) => p.alive);
  const startTime = Date.now();
  const { messages } = buildMessagesForPrompt(prompt);
  const validSeats = alivePlayers.map((p) => p.seat);

  try {
    const completion = await generateCompletionWithParseRetry(
      mergeOptionsFromModelRef(player.agentProfile!.modelRef, {
        model: player.agentProfile!.modelRef.model,
        messages,
        temperature: GAME_TEMPERATURE.ACTION,
        response_format: { type: "json_object" },
      }),
      (cleaned) => {
        const parsedSeat = parseLLMDisplaySeat(cleaned, validSeats, ["seat", "targetSeat", "target", "kill"]);
        return parsedSeat === null ? parseFail() : parseOk(parsedSeat);
      },
      jsonRetryInstruction(JSON.stringify({ seat: 2 }))
    );
    const parsedSeat = completion.parsed ?? undefined;

    await aiLogger.log({
      type: "wolf_action",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: completion.cleaned,
        raw: completion.result.content,
        rawResponse: JSON.stringify(completion.result.raw, null, 2),
        finishReason: completion.result.raw.choices?.[0]?.finish_reason,
        parsed: { targetSeat: parsedSeat },
        duration: Date.now() - startTime
      },
    });

    return parsedSeat;
  } catch (error) {
    console.warn("[wolfcha] generateWolfAction failed, skipping wolf kill:", error);
    await aiLogger.log({
      type: "wolf_action",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: "",
        parsed: { targetSeat: undefined },
        duration: Date.now() - startTime,
      },
      error: String(error),
    });
    return undefined;
  }
}

export type WitchAction =
  | { type: "save" }
  | { type: "poison"; target: number }
  | { type: "pass" };

export async function generateWitchAction(
  state: GameState,
  player: Player,
  wolfTarget: number | undefined
): Promise<WitchAction> {
  const prompt = resolvePhasePrompt("NIGHT_WITCH_ACTION", state, player, { wolfTarget });
  const startTime = Date.now();
  const { messages } = buildMessagesForPrompt(prompt);
  const canSave =
    !state.roleAbilities.witchHealUsed &&
    wolfTarget !== undefined;
  const canPoison = !state.roleAbilities.witchPoisonUsed;
  const validPoisonSeats = state.players
    .filter((p) => p.alive && p.playerId !== player.playerId)
    .map((p) => p.seat);
  const passAction: WitchAction = { type: "pass" };

  try {
    const completion = await generateCompletionWithParseRetry<WitchAction>(
      mergeOptionsFromModelRef(player.agentProfile!.modelRef, {
        model: player.agentProfile!.modelRef.model,
        messages,
        temperature: GAME_TEMPERATURE.ACTION,
        response_format: { type: "json_object" },
      }),
      (cleaned) => {
        const parsed = parseLLMJson<unknown>(cleaned);
        if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parseFail();

        const record = parsed as Record<string, unknown>;
        const action = String(record.action ?? record.type ?? "").trim().toLowerCase();
        const seatValue = record.seat ?? record.targetSeat ?? record.target ?? record.poison;
        const wantsPass =
          action.includes("pass") ||
          action.includes("skip") ||
          action.includes("none") ||
          seatValue === null ||
          seatValue === 0 ||
          seatValue === "0";

        if (wantsPass) return parseOk(passAction);
        if (action === "save" || action === "heal") {
          return canSave ? parseOk({ type: "save" }) : parseFail();
        }
        if (action === "poison") {
          if (!canPoison) return parseFail();
          const target = parseLLMDisplaySeat(cleaned, validPoisonSeats, ["seat", "targetSeat", "target", "poison"]);
          return target === null ? parseFail() : parseOk({ type: "poison", target });
        }

        return parseFail();
      },
      jsonRetryInstruction([
        canSave ? JSON.stringify({ action: "save" }) : null,
        canPoison && validPoisonSeats.length > 0 ? JSON.stringify({ action: "poison", seat: (firstSeat(validPoisonSeats) ?? 0) + 1 }) : null,
        JSON.stringify({ action: "pass" }),
      ].filter(Boolean).join(" 或 / or "))
    );
    const parsedAction = completion.parsed ?? passAction;

    await aiLogger.log({
      type: "witch_action",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: completion.cleaned,
        raw: completion.result.content,
        rawResponse: JSON.stringify(completion.result.raw, null, 2),
        finishReason: completion.result.raw.choices?.[0]?.finish_reason,
        parsed: { ...parsedAction, attempts: completion.attempts },
        duration: Date.now() - startTime,
      },
    });

    return parsedAction;
  } catch (error) {
    console.warn("[wolfcha] generateWitchAction failed, passing witch action:", error);
    await aiLogger.log({
      type: "witch_action",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: "",
        parsed: passAction,
        duration: Date.now() - startTime,
      },
      error: String(error),
    });
    return passAction;
  }
}

// ...

export async function generateGuardAction(
  state: GameState,
  player: Player
): Promise<number | undefined> {
  const prompt = resolvePhasePrompt("NIGHT_GUARD_ACTION", state, player);
  const lastTarget = state.nightActions.lastGuardTarget;
  const alivePlayers = state.players.filter(
    (p) => p.alive && p.seat !== lastTarget
  );
  const startTime = Date.now();
  const { messages } = buildMessagesForPrompt(prompt);
  const validSeats = alivePlayers.map((p) => p.seat);

  try {
    const completion = await generateCompletionWithParseRetry(
      mergeOptionsFromModelRef(player.agentProfile!.modelRef, {
        model: player.agentProfile!.modelRef.model,
        messages,
        temperature: GAME_TEMPERATURE.ACTION,
        response_format: { type: "json_object" },
      }),
      (cleaned) => {
        const parsedSeat = parseLLMDisplaySeat(cleaned, validSeats, ["seat", "targetSeat", "target", "protect"]);
        return parsedSeat === null ? parseFail() : parseOk(parsedSeat);
      },
      jsonRetryInstruction(JSON.stringify({ seat: 3 }))
    );
    const parsedSeat = completion.parsed ?? undefined;

    await aiLogger.log({
      type: "guard_action",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: completion.cleaned,
        raw: completion.result.content,
        rawResponse: JSON.stringify(completion.result.raw, null, 2),
        finishReason: completion.result.raw.choices?.[0]?.finish_reason,
        parsed: { targetSeat: parsedSeat },
        duration: Date.now() - startTime,
      },
    });

    return parsedSeat;
  } catch (error) {
    console.warn("[wolfcha] generateGuardAction failed, skipping guard protection:", error);
    await aiLogger.log({
      type: "guard_action",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: "",
        parsed: { targetSeat: undefined },
        duration: Date.now() - startTime,
      },
      error: String(error),
    });
    return undefined;
  }
}

// ...

export async function generateHunterShoot(
  state: GameState,
  player: Player
): Promise<number | null> {
  const prompt = resolvePhasePrompt("HUNTER_SHOOT", state, player);
  const alivePlayers = state.players.filter(
    (p) => p.alive && p.playerId !== player.playerId
  );
  const startTime = Date.now();
  const { messages } = buildMessagesForPrompt(prompt);
  const validSeats = alivePlayers.map((p) => p.seat);

  try {
    const completion = await generateCompletionWithParseRetry<number | null>(
      mergeOptionsFromModelRef(player.agentProfile!.modelRef, {
        model: player.agentProfile!.modelRef.model,
        messages,
        temperature: GAME_TEMPERATURE.ACTION,
        response_format: { type: "json_object" },
      }),
      (cleaned) => {
        const parsed = parseLLMJson<{ seat?: unknown; targetSeat?: unknown; target?: unknown; shoot?: unknown; action?: unknown }>(cleaned);
        if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parseFail();

        const action = String(parsed.action ?? "").toLowerCase();
        const rawSeat = parsed.seat ?? parsed.targetSeat ?? parsed.target ?? parsed.shoot;
        const wantsPass =
          action.includes("pass") ||
          action.includes("skip") ||
          action.includes("不开") ||
          rawSeat === null ||
          rawSeat === 0 ||
          rawSeat === "0" ||
          rawSeat === "pass";
        if (wantsPass) return parseOk(null);

        const parsedTarget = parseLLMDisplaySeat(cleaned, validSeats, ["seat", "targetSeat", "target", "shoot"]);
        return parsedTarget === null ? parseFail() : parseOk(parsedTarget);
      },
      jsonRetryInstruction(JSON.stringify({ seat: 5 }))
    );
    const parsedTarget = completion.parsed;

    await aiLogger.log({
      type: "hunter_shoot",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: completion.cleaned,
        raw: completion.result.content,
        rawResponse: JSON.stringify(completion.result.raw, null, 2),
        finishReason: completion.result.raw.choices?.[0]?.finish_reason,
        parsed: { targetSeat: parsedTarget },
        duration: Date.now() - startTime,
      },
    });

    return parsedTarget;
  } catch (error) {
    console.warn("[wolfcha] generateHunterShoot failed, passing hunter shot:", error);
    await aiLogger.log({
      type: "hunter_shoot",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: "",
        parsed: { targetSeat: null },
        duration: Date.now() - startTime,
      },
      error: String(error),
    });
    return null;
  }
}

/**
 * AI 白狼王自爆决策：返回目标座位号（自爆）或 null（不自爆）
 */
export async function generateWhiteWolfKingBoomDecision(
  state: GameState,
  player: Player
): Promise<number | null> {
  const prompt = resolvePhasePrompt("WHITE_WOLF_KING_BOOM", state, player);
  const alivePlayers = state.players.filter(
    (p) => p.alive && p.playerId !== player.playerId
  );
  const startTime = Date.now();
  const { messages } = buildMessagesForPrompt(prompt);
  const validSeats = alivePlayers.map((p) => p.seat);

  if (validSeats.length === 0) return null;

  try {
    const completion = await generateCompletionWithParseRetry<number | null>(
      mergeOptionsFromModelRef(player.agentProfile!.modelRef, {
        model: player.agentProfile!.modelRef.model,
        messages,
        temperature: GAME_TEMPERATURE.ACTION,
        response_format: { type: "json_object" },
      }),
      (cleaned) => {
        const parsed = parseLLMJson<unknown>(cleaned);
        if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parseFail();

        const record = parsed as Record<string, unknown>;
        const action = String(record.action ?? record.type ?? "").trim().toLowerCase();
        const seatValue = record.seat ?? record.targetSeat ?? record.target ?? record.boom;
        const wantsPass =
          action.includes("pass") ||
          action.includes("skip") ||
          action.includes("none") ||
          seatValue === null ||
          seatValue === 0 ||
          seatValue === "0";

        if (wantsPass) return parseOk(null);
        const wantsBoom =
          action === "" ||
          action.includes("boom") ||
          action.includes("explode") ||
          action.includes("self");
        if (!wantsBoom) return parseFail();

        const target = parseLLMDisplaySeat(cleaned, validSeats, ["seat", "targetSeat", "target", "boom"]);
        return target === null ? parseFail() : parseOk(target);
      },
      jsonRetryInstruction(`${JSON.stringify({ action: "boom", seat: (firstSeat(validSeats) ?? 0) + 1 })} 或 / or ${JSON.stringify({ action: "pass" })}`)
    );
    const parsedTarget = completion.parsed;

    await aiLogger.log({
      type: "wwk_boom_decision",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: completion.cleaned,
        raw: completion.result.content,
        rawResponse: JSON.stringify(completion.result.raw, null, 2),
        finishReason: completion.result.raw.choices?.[0]?.finish_reason,
        parsed: { targetSeat: parsedTarget, attempts: completion.attempts },
        duration: Date.now() - startTime,
      },
    });

    return parsedTarget;
  } catch (error) {
    console.warn("[wolfcha] generateWhiteWolfKingBoomDecision failed, passing self-destruct:", error);
    await aiLogger.log({
      type: "wwk_boom_decision",
      request: {
        model: player.agentProfile!.modelRef.model,
        messages,
        player: { playerId: player.playerId, displayName: player.displayName, seat: player.seat, role: player.role },
      },
      response: {
        content: "",
        parsed: { targetSeat: null },
        duration: Date.now() - startTime,
      },
      error: String(error),
    });
    return null;
  }
}
