"""Deterministic CS Major autochess rules for the reviewed local service.""" from __future__ import annotations import hashlib import json import re from importlib import resources from typing import Any DATA_FILES = ("rules.json", "players.json", "coaches.json", "narratives.json") DATA_ROOT = "data/cs_autochess" MIN_NARRATIVE_FRAGMENTS = 120 MULTI_REPLY_MARKER = "\x1eJWEBOT_MULTI_REPLY_V1\x1e" def _load_json_file(filename: str) -> dict[str, Any]: if filename not in DATA_FILES: raise ValueError(f"unknown CS autochess data file: {filename}") path = resources.files(__package__).joinpath(DATA_ROOT, filename) with path.open("r", encoding="utf-8") as handle: payload = json.load(handle) if not isinstance(payload, dict): raise ValueError(f"{filename} must contain a JSON object") if int(payload.get("schema_version", 0)) != 1: raise ValueError(f"{filename} has unsupported schema_version") return payload def _mapping(value: Any, context: str) -> dict[str, Any]: if not isinstance(value, dict): raise ValueError(f"{context} must be an object") return dict(value) def _sequence(value: Any, context: str) -> list[Any]: if not isinstance(value, list): raise ValueError(f"{context} must be a list") return list(value) def _text(value: Any, context: str) -> str: result = str(value).strip() if not result: raise ValueError(f"{context} must be a non-empty string") return result def _int_value(value: Any, context: str) -> int: if isinstance(value, bool): raise ValueError(f"{context} must be an integer") try: return int(value) except (TypeError, ValueError) as exc: raise ValueError(f"{context} must be an integer") from exc def _float_value(value: Any, context: str) -> float: if isinstance(value, bool): raise ValueError(f"{context} must be a number") try: return float(value) except (TypeError, ValueError) as exc: raise ValueError(f"{context} must be a number") from exc def _int_key_int_dict(value: Any, context: str) -> dict[int, int]: return { _int_value(key, f"{context}.key"): _int_value(item, f"{context}.{key}") for key, item in _mapping(value, context).items() } def _str_key_int_dict(value: Any, context: str) -> dict[str, int]: return { _text(key, f"{context}.key"): _int_value(item, f"{context}.{key}") for key, item in _mapping(value, context).items() } def _section(rules: dict[str, Any], key: str) -> dict[str, Any]: if key not in rules: raise ValueError(f"rules.json missing {key}") return _mapping(rules[key], f"rules.{key}") def _load_rules() -> dict[str, Any]: rules = _load_json_file("rules.json") for key in ( "tournament", "ai", "roster", "economy", "coach", "shop", "draft", "tactics", "battle", "pve_events", "special_events", ): _section(rules, key) return rules def _load_player_pool() -> tuple[dict[str, Any], ...]: payload = _load_json_file("players.json") players = _sequence(payload.get("players"), "players.players") required = { "id", "name", "cost", "role", "nation", "team", "teams", "firepower", "stability", "clutch", "leadership", } seen: set[str] = set() normalized: list[dict[str, Any]] = [] for index, raw_item in enumerate(players, start=1): item = _mapping(raw_item, f"players[{index}]") missing = sorted(required - item.keys()) if missing: raise ValueError(f"players[{index}] missing {', '.join(missing)}") card_id = _text(item["id"], f"players[{index}].id") if card_id in seen: raise ValueError(f"duplicate player id: {card_id}") seen.add(card_id) team = _text(item["team"], f"players[{index}].team") teams = [ _text(team_name, f"players[{index}].teams") for team_name in _sequence(item["teams"], f"players[{index}].teams") ] if team not in teams: teams.insert(0, team) teams = [team, *[team_name for team_name in teams if team_name != team]][:3] cost = _int_value(item["cost"], f"players[{index}].cost") if not 1 <= cost <= 5: raise ValueError(f"players[{index}].cost must be 1..5") normalized.append( { "id": card_id, "name": _text(item["name"], f"players[{index}].name"), "cost": cost, "role": _text(item["role"], f"players[{index}].role"), "nation": _text(item["nation"], f"players[{index}].nation"), "team": team, "teams": teams, "firepower": _int_value(item["firepower"], f"players[{index}].firepower"), "stability": _int_value(item["stability"], f"players[{index}].stability"), "clutch": _int_value(item["clutch"], f"players[{index}].clutch"), "leadership": _int_value(item["leadership"], f"players[{index}].leadership"), } ) if len(normalized) < 100: raise ValueError("CS autochess player pool must contain at least 100 cards") return tuple(normalized) def _load_coach_pool() -> tuple[dict[str, Any], ...]: payload = _load_json_file("coaches.json") coaches = _sequence(payload.get("coaches"), "coaches.coaches") required = {"id", "name", "level", "style", "firepower", "stability", "clutch", "bonus"} seen: set[str] = set() normalized: list[dict[str, Any]] = [] for index, raw_item in enumerate(coaches, start=1): item = _mapping(raw_item, f"coaches[{index}]") missing = sorted(required - item.keys()) if missing: raise ValueError(f"coaches[{index}] missing {', '.join(missing)}") coach_id = _text(item["id"], f"coaches[{index}].id") if coach_id in seen: raise ValueError(f"duplicate coach id: {coach_id}") seen.add(coach_id) normalized.append( { "id": coach_id, "name": _text(item["name"], f"coaches[{index}].name"), "level": _int_value(item["level"], f"coaches[{index}].level"), "style": _text(item["style"], f"coaches[{index}].style"), "firepower": _int_value(item["firepower"], f"coaches[{index}].firepower"), "stability": _int_value(item["stability"], f"coaches[{index}].stability"), "clutch": _int_value(item["clutch"], f"coaches[{index}].clutch"), "bonus": _int_value(item["bonus"], f"coaches[{index}].bonus"), } ) if len(normalized) < 10: raise ValueError("CS autochess coach pool must contain at least 10 coaches") return tuple(normalized) def _load_narratives() -> dict[str, tuple[str, ...]]: payload = _load_json_file("narratives.json") fragments = _mapping(payload.get("fragments"), "narratives.fragments") normalized: dict[str, tuple[str, ...]] = {} for category, raw_items in fragments.items(): key = _text(category, "narratives category") items = tuple( _text(item, f"narratives.{key}") for item in _sequence(raw_items, f"narratives.{key}") ) if not items: raise ValueError(f"narratives.{key} must not be empty") normalized[key] = items total = sum(len(items) for items in normalized.values()) if total < MIN_NARRATIVE_FRAGMENTS: raise ValueError( f"CS autochess needs at least {MIN_NARRATIVE_FRAGMENTS} narrative fragments" ) return normalized def _synergy_rule(value: Any, context: str) -> dict[str, Any]: raw = _mapping(value, context) thresholds = [] for index, item in enumerate(_sequence(raw.get("thresholds"), f"{context}.thresholds"), start=1): rule = _mapping(item, f"{context}.thresholds[{index}]") thresholds.append( { "min_count": _int_value(rule.get("min_count"), f"{context}.thresholds[{index}].min_count"), "bonus": _int_value(rule.get("bonus"), f"{context}.thresholds[{index}].bonus"), } ) thresholds.sort(key=lambda item: int(item["min_count"]), reverse=True) return { "max_sources": _int_value(raw.get("max_sources"), f"{context}.max_sources"), "thresholds": thresholds, } _RULES = _load_rules() _TOURNAMENT_RULES = _section(_RULES, "tournament") _AI_RULES = _section(_RULES, "ai") _ROSTER_RULES = _section(_RULES, "roster") _ECONOMY_RULES = _section(_RULES, "economy") _COACH_RULES = _section(_RULES, "coach") _SHOP_RULES = _section(_RULES, "shop") _DRAFT_RULES = _section(_RULES, "draft") _TACTIC_RULES = _section(_RULES, "tactics") _BATTLE_RULES = _section(_RULES, "battle") MIN_PLAYERS = _int_value(_TOURNAMENT_RULES.get("min_players"), "rules.tournament.min_players") MAX_PLAYERS = _int_value(_TOURNAMENT_RULES.get("max_players"), "rules.tournament.max_players") HOST_NAME = _text(_TOURNAMENT_RULES.get("host_name"), "rules.tournament.host_name") EVENT_TITLE_TEMPLATE = _text( _TOURNAMENT_RULES.get("event_title_template"), "rules.tournament.event_title_template", ) AI_PERSON_KEY = _text(_AI_RULES.get("primary_person_key"), "rules.ai.primary_person_key") AI_DISPLAY_NAME = _text(_AI_RULES.get("primary_display_name"), "rules.ai.primary_display_name") AI_SECOND_PERSON_KEY = _text(_AI_RULES.get("secondary_person_key"), "rules.ai.secondary_person_key") AI_SECOND_DISPLAY_NAME = _text(_AI_RULES.get("secondary_display_name"), "rules.ai.secondary_display_name") AI_POWER_MULTIPLIER = _float_value(_AI_RULES.get("power_multiplier"), "rules.ai.power_multiplier") STARTING_GOLD = _int_value(_ECONOMY_RULES.get("starting_gold"), "rules.economy.starting_gold") STARTING_HP = _int_value(_ECONOMY_RULES.get("starting_hp"), "rules.economy.starting_hp") _ROUND_INCOME_RULES = _mapping(_ECONOMY_RULES.get("round_income"), "rules.economy.round_income") ROUND_INCOME_BASE = _int_value(_ROUND_INCOME_RULES.get("base"), "rules.economy.round_income.base") ROUND_INTEREST_DIVISOR = _int_value(_ROUND_INCOME_RULES.get("interest_divisor"), "rules.economy.round_income.interest_divisor") ROUND_INTEREST_CAP = _int_value(_ROUND_INCOME_RULES.get("interest_cap"), "rules.economy.round_income.interest_cap") _TRAINING_REWARDS = _mapping(_ECONOMY_RULES.get("training_rewards"), "rules.economy.training_rewards") TRAINING_WIN_GOLD = _int_value(_TRAINING_REWARDS.get("win_gold"), "rules.economy.training_rewards.win_gold") TRAINING_LOSS_GOLD = _int_value(_TRAINING_REWARDS.get("loss_gold"), "rules.economy.training_rewards.loss_gold") TRAINING_BYE_GOLD = _int_value(_TRAINING_REWARDS.get("bye_gold"), "rules.economy.training_rewards.bye_gold") _SERIES_REWARDS = _mapping(_ECONOMY_RULES.get("series_rewards"), "rules.economy.series_rewards") GROUP_SERIES_WIN_GOLD = _int_value(_SERIES_REWARDS.get("group_win_gold"), "rules.economy.series_rewards.group_win_gold") PLAYOFF_SERIES_WIN_GOLD = _int_value(_SERIES_REWARDS.get("playoff_win_gold"), "rules.economy.series_rewards.playoff_win_gold") STARTER_COUNT = _int_value(_ROSTER_RULES.get("starter_count"), "rules.roster.starter_count") BENCH_LIMIT = _int_value(_ROSTER_RULES.get("bench_limit"), "rules.roster.bench_limit") STAR_MULTIPLIERS = { _int_value(star, "rules.roster.star_multipliers.key"): _float_value(multiplier, f"rules.roster.star_multipliers.{star}") for star, multiplier in _mapping(_ROSTER_RULES.get("star_multipliers"), "rules.roster.star_multipliers").items() } POWER_WEIGHTS = _mapping(_ROSTER_RULES.get("unit_power_weights"), "rules.roster.unit_power_weights") POWER_FIREPOWER_WEIGHT = _float_value(POWER_WEIGHTS.get("firepower"), "rules.roster.unit_power_weights.firepower") POWER_STABILITY_WEIGHT = _float_value(POWER_WEIGHTS.get("stability"), "rules.roster.unit_power_weights.stability") POWER_CLUTCH_WEIGHT = _float_value(POWER_WEIGHTS.get("clutch"), "rules.roster.unit_power_weights.clutch") POWER_LEADERSHIP_THRESHOLD = _int_value(POWER_WEIGHTS.get("leadership_threshold"), "rules.roster.unit_power_weights.leadership_threshold") POWER_LEADERSHIP_SCALE = _float_value(POWER_WEIGHTS.get("leadership_scale"), "rules.roster.unit_power_weights.leadership_scale") TEAM_SYNERGY_RULE = _synergy_rule(_ROSTER_RULES.get("team_synergy"), "rules.roster.team_synergy") NATION_SYNERGY_RULE = _synergy_rule(_ROSTER_RULES.get("nation_synergy"), "rules.roster.nation_synergy") COACH_UPGRADE_COSTS = _int_key_int_dict(_COACH_RULES.get("upgrade_costs"), "rules.coach.upgrade_costs") COACH_BONUSES = _int_key_int_dict(_COACH_RULES.get("fallback_bonuses"), "rules.coach.fallback_bonuses") SHOP_SIZE = _int_value(_SHOP_RULES.get("size"), "rules.shop.size") REFRESH_COST = _int_value(_SHOP_RULES.get("refresh_cost"), "rules.shop.refresh_cost") COST_POOL_COPIES = _int_key_int_dict(_SHOP_RULES.get("cost_pool_copies"), "rules.shop.cost_pool_copies") SHOP_ODDS = { _int_value(tier, "rules.shop.odds.tier"): _int_key_int_dict(costs, f"rules.shop.odds.{tier}") for tier, costs in _mapping(_SHOP_RULES.get("odds"), "rules.shop.odds").items() } DRAFT_ROUNDS = _int_value(_DRAFT_RULES.get("rounds"), "rules.draft.rounds") DRAFT_COACH_ROUND = _int_value(_DRAFT_RULES.get("coach_round"), "rules.draft.coach_round") DRAFT_COACH_CHOICE_COUNT = _int_value(_DRAFT_RULES.get("coach_choice_count"), "rules.draft.coach_choice_count") DRAFT_PLAYER_CHOICE_COUNT = _int_value(_DRAFT_RULES.get("player_choice_count"), "rules.draft.player_choice_count") DRAFT_PLAYER_COPY_COUNT = _int_value(_DRAFT_RULES.get("player_copy_count"), "rules.draft.player_copy_count") DRAFT_ROUND_TWO_PREFERRED_COSTS = tuple( _int_value(item, "rules.draft.round_two_preferred_costs") for item in _sequence(_DRAFT_RULES.get("round_two_preferred_costs"), "rules.draft.round_two_preferred_costs") ) DRAFT_LATER_ROUND_PREFERRED_COSTS = tuple( _int_value(item, "rules.draft.later_round_preferred_costs") for item in _sequence(_DRAFT_RULES.get("later_round_preferred_costs"), "rules.draft.later_round_preferred_costs") ) TACTICS = tuple( _text(item, "rules.tactics.names") for item in _sequence(_TACTIC_RULES.get("names"), "rules.tactics.names") ) TACTIC_COUNTERS = { _text(key, "rules.tactics.counters.key"): _text(value, f"rules.tactics.counters.{key}") for key, value in _mapping(_TACTIC_RULES.get("counters"), "rules.tactics.counters").items() } ATTRIBUTE_COUNTERS = tuple( ( _text(pair[0], "rules.tactics.attribute_counters.strong"), _text(pair[1], "rules.tactics.attribute_counters.weak"), ) for pair in _sequence(_TACTIC_RULES.get("attribute_counters"), "rules.tactics.attribute_counters") ) ROLE_COUNTERS = { _text(key, "rules.tactics.role_counters.key"): _text(value, f"rules.tactics.role_counters.{key}") for key, value in _mapping(_TACTIC_RULES.get("role_counters"), "rules.tactics.role_counters").items() } TACTIC_AXIS_MULTIPLIER = _float_value(_TACTIC_RULES.get("selected_axis_multiplier"), "rules.tactics.selected_axis_multiplier") TACTIC_AXIS_PERCENT_DIVISOR = _float_value(_TACTIC_RULES.get("axis_percent_divisor"), "rules.tactics.axis_percent_divisor") TACTIC_AXIS_PERCENT_CAP = _float_value(_TACTIC_RULES.get("axis_percent_cap"), "rules.tactics.axis_percent_cap") TACTIC_ROLE_PERCENT_SCALE = _float_value(_TACTIC_RULES.get("role_percent_scale"), "rules.tactics.role_percent_scale") TACTIC_ROLE_PERCENT_CAP = _float_value(_TACTIC_RULES.get("role_percent_cap"), "rules.tactics.role_percent_cap") TACTIC_COUNTER_PERCENT = _float_value(_TACTIC_RULES.get("tactic_counter_percent"), "rules.tactics.tactic_counter_percent") TACTIC_TOTAL_PERCENT_CAP = _float_value(_TACTIC_RULES.get("total_percent_cap"), "rules.tactics.total_percent_cap") MAP_POOL = tuple( _text(item, "rules.battle.maps") for item in _sequence(_BATTLE_RULES.get("maps"), "rules.battle.maps") ) _BATTLE_DAMAGE = _mapping(_BATTLE_RULES.get("damage"), "rules.battle.damage") BATTLE_BO5_SWEEP_DAMAGE = _int_value(_BATTLE_DAMAGE.get("bo5_sweep"), "rules.battle.damage.bo5_sweep") BATTLE_SWEEP_DAMAGE = _int_value(_BATTLE_DAMAGE.get("sweep"), "rules.battle.damage.sweep") BATTLE_NON_SWEEP_DAMAGE = _int_value(_BATTLE_DAMAGE.get("non_sweep"), "rules.battle.damage.non_sweep") BATTLE_MR_ROUNDS = _int_value(_BATTLE_RULES.get("mr_rounds"), "rules.battle.mr_rounds") BATTLE_TARGET_SCORE = _int_value(_BATTLE_RULES.get("target_score"), "rules.battle.target_score") BATTLE_OVERTIME_START_SCORE = _int_value(_BATTLE_RULES.get("overtime_start_score"), "rules.battle.overtime_start_score") BATTLE_OVERTIME_TARGET_SCORE = _int_value(_BATTLE_RULES.get("overtime_target_score"), "rules.battle.overtime_target_score") BATTLE_OVERTIME_WIN_BY = _int_value(_BATTLE_RULES.get("overtime_win_by"), "rules.battle.overtime_win_by") BATTLE_OVERTIME_SAFETY_CAP = _int_value(_BATTLE_RULES.get("overtime_safety_cap", 72), "rules.battle.overtime_safety_cap") BATTLE_OVERTIME_SEGMENT_ROUNDS = _int_value(_BATTLE_RULES.get("overtime_segment_rounds"), "rules.battle.overtime_segment_rounds") MATCH_BEST_OF = _str_key_int_dict(_BATTLE_RULES.get("match_best_of"), "rules.battle.match_best_of") _ROUND_PROBABILITY_RULES = _mapping(_BATTLE_RULES.get("round_probability"), "rules.battle.round_probability") ROUND_PROBABILITY_BASE = _float_value(_ROUND_PROBABILITY_RULES.get("base"), "rules.battle.round_probability.base") ROUND_PROBABILITY_MIN = _float_value(_ROUND_PROBABILITY_RULES.get("min"), "rules.battle.round_probability.min") ROUND_PROBABILITY_MAX = _float_value(_ROUND_PROBABILITY_RULES.get("max"), "rules.battle.round_probability.max") ROUND_POWER_EDGE_CAP = _float_value(_ROUND_PROBABILITY_RULES.get("power_edge_cap"), "rules.battle.round_probability.power_edge_cap") ROUND_POWER_EDGE_SCALE = _float_value(_ROUND_PROBABILITY_RULES.get("power_edge_scale"), "rules.battle.round_probability.power_edge_scale") ROUND_PRESSURE_CAP = _float_value(_ROUND_PROBABILITY_RULES.get("pressure_cap"), "rules.battle.round_probability.pressure_cap") ROUND_PRESSURE_SCALE = _float_value(_ROUND_PROBABILITY_RULES.get("pressure_scale"), "rules.battle.round_probability.pressure_scale") ROUND_FIRST_HALF_SIDE_SWING = _float_value(_ROUND_PROBABILITY_RULES.get("first_half_side_swing"), "rules.battle.round_probability.first_half_side_swing") ROUND_SECOND_HALF_SIDE_SWING = _float_value(_ROUND_PROBABILITY_RULES.get("second_half_side_swing"), "rules.battle.round_probability.second_half_side_swing") ROUND_JITTER_MOD = _int_value(_ROUND_PROBABILITY_RULES.get("jitter_mod"), "rules.battle.round_probability.jitter_mod") ROUND_JITTER_CENTER = _int_value(_ROUND_PROBABILITY_RULES.get("jitter_center"), "rules.battle.round_probability.jitter_center") ROUND_JITTER_DIVISOR = _float_value(_ROUND_PROBABILITY_RULES.get("jitter_divisor"), "rules.battle.round_probability.jitter_divisor") SCORE_BANDS = tuple( { "max_loser_score": _int_value(item.get("max_loser_score"), "rules.battle.score_bands.max_loser_score"), "label": _text(item.get("label"), "rules.battle.score_bands.label"), } for item in ( _mapping(raw_item, "rules.battle.score_bands.item") for raw_item in _sequence(_BATTLE_RULES.get("score_bands"), "rules.battle.score_bands") ) ) PVE_EVENTS = { key: ( _text(item.get("name"), f"rules.pve_events.{key}.name"), _int_value(item.get("target"), f"rules.pve_events.{key}.target"), _int_value(item.get("win_gold"), f"rules.pve_events.{key}.win_gold"), _int_value(item.get("loss_gold"), f"rules.pve_events.{key}.loss_gold"), ) for key, item in ( (str(name), _mapping(value, f"rules.pve_events.{name}")) for name, value in _section(_RULES, "pve_events").items() ) } SPECIAL_EVENTS = { key: ( _text(item.get("name"), f"rules.special_events.{key}.name"), _int_value(item.get("target"), f"rules.special_events.{key}.target"), _int_value(item.get("win_gold"), f"rules.special_events.{key}.win_gold"), _int_value(item.get("loss_gold"), f"rules.special_events.{key}.loss_gold"), ) for key, item in ( (str(name), _mapping(value, f"rules.special_events.{name}")) for name, value in _section(_RULES, "special_events").items() ) } PLAYER_POOL = _load_player_pool() CARD_BY_ID = {card["id"]: card for card in PLAYER_POOL} COACH_POOL = _load_coach_pool() COACH_BY_ID = {coach["id"]: coach for coach in COACH_POOL} NARRATIVE_FRAGMENTS = _load_narratives() def _hash_int(seed: str) -> int: return int.from_bytes( hashlib.sha256(seed.encode("utf-8", "replace")).digest()[:8], "big", ) def _pick_fragment(category: str, seed: str, **values: Any) -> str: fragments = tuple(NARRATIVE_FRAGMENTS.get(category, ())) if not fragments: return "" template = fragments[_hash_int(f"{category}|{seed}") % len(fragments)] return template.format(**values) def _narrative_inventory_size() -> int: return sum(len(items) for items in NARRATIVE_FRAGMENTS.values()) def _pack_response_parts(parts: list[str]) -> str: cleaned = [part.strip() for part in parts if part.strip()] if len(cleaned) <= 1: return cleaned[0] if cleaned else "没有可发送内容" return MULTI_REPLY_MARKER + json.dumps( cleaned, ensure_ascii=False, separators=(",", ":"), ) def _unpack_response_parts(value: str) -> list[str]: if not value.startswith(MULTI_REPLY_MARKER): return [value] try: payload = json.loads(value[len(MULTI_REPLY_MARKER) :]) except json.JSONDecodeError: return [value] if not isinstance(payload, list): return [value] parts = [str(item).strip() for item in payload if str(item).strip()] return parts or [value] def _round_robin_rounds(keys: list[str]) -> list[list[list[str]]]: rotation: list[str | None] = list(keys) if len(rotation) % 2: rotation.append(None) rounds: list[list[list[str]]] = [] for _ in range(len(rotation) - 1): pairs: list[list[str]] = [] for index in range(len(rotation) // 2): first = rotation[index] second = rotation[-index - 1] if first is not None and second is not None: pairs.append([first, second]) rounds.append(pairs) rotation = [rotation[0], rotation[-1], *rotation[1:-1]] return rounds def build_round_plan(keys: list[str]) -> list[dict[str, Any]]: if not MIN_PLAYERS <= len(keys) <= MAX_PLAYERS: raise ValueError("参赛人数必须为 2~8 人") plan: list[dict[str, Any]] = [ { "kind": "pve", "phase": "pve_training", "name": PVE_EVENTS["pve_training"][0], } ] group_rounds = _round_robin_rounds(keys) challenge_after = 1 if len(group_rounds) <= 2 else 2 for day, pairs in enumerate(group_rounds, start=1): plan.append( { "kind": "group", "phase": f"group_{day}", "name": f"小组赛第 {day} 轮", "day": day, "pairs": pairs, "last_group_day": day == len(group_rounds), } ) if day == challenge_after: phase = "pve_media" if len(keys) % 2 == 0 else "pve_sponsor" plan.append( { "kind": "pve", "phase": phase, "name": PVE_EVENTS[phase][0], } ) if len(keys) >= 4: plan.append( { "kind": "semifinal", "phase": "semifinal", "name": "淘汰赛半决赛(1v4、2v3)", "pairs": [[keys[0], keys[3]], [keys[1], keys[2]]] if len(keys) == 4 else [], "pairing": "standings", } ) plan.append( { "kind": "final", "phase": "final", "name": "总决赛", } ) return plan def _current_stage(state: dict[str, Any]) -> dict[str, Any]: round_number = int(state["round_number"]) plan = state.get("round_plan", []) if not isinstance(plan, list) or not 1 <= round_number <= len(plan): return {"kind": "complete", "phase": "complete", "name": "赛事结束"} stage = plan[round_number - 1] return stage if isinstance(stage, dict) else { "kind": "complete", "phase": "complete", "name": "赛事结束", } def _host_text(text: str) -> str: return text if text.startswith(f"{HOST_NAME}:") else f"{HOST_NAME}:{text}" def _human_keys(state: dict[str, Any]) -> list[str]: return [ key for key in state["seed_order"] if not state["players"][key].get("is_ai") ] def _reset_preparation(state: dict[str, Any]) -> None: human_keys = _human_keys(state) if not human_keys: state["preparation_order"] = [] state["preparation_cursor"] = 0 return offset = (int(state["round_number"]) - 1) % len(human_keys) order = [*human_keys[offset:], *human_keys[:offset]] state["preparation_order"] = order state["preparation_cursor"] = 0 for key in state["join_order"]: state["players"][key]["ready"] = bool( state["players"][key].get("is_ai") ) def _current_preparer(state: dict[str, Any]) -> str | None: order = state.get("preparation_order", []) cursor = int(state.get("preparation_cursor", 0)) if not isinstance(order, list) or not 0 <= cursor < len(order): return None return str(order[cursor]) def _preparation_prompt(state: dict[str, Any]) -> str: current = _current_preparer(state) if current is None: return _host_text("全员准备完毕,主持人可以发送 /自走棋结算。") name = state["players"][current]["display_name"] return _host_text( f"{name},请做好赛前准备;完成后发送 /结束回合。" ) def _format_description(state: dict[str, Any]) -> str: team_count = len(state["join_order"]) if state.get("ai_test_variant") == "two_plus_two": return ( "本届采用 2 人+2 AI 测试赛:报名种子 1v4、2v3 进行 BO5 " "半决赛,胜者进入 BO5 总决赛" ) if state.get("ai_test_mode"): return ( "本届采用 3 人+1 AI 测试赛:报名种子 1v4、2v3 进行 BO5 " "半决赛,胜者进入 BO5 总决赛" ) if team_count == 2: return "本届两队在三轮赛前活动后直接进行 BO5 总决赛" if team_count == 3: return "本届三队进行 BO3 单循环,前两名进入 BO5 总决赛" if team_count == 4: return "本届按报名种子 1v4、2v3 进行 BO5 半决赛和总决赛" return ( f"本届 {team_count} 队进行 BO3 单循环,前四名按 1v4、2v3 " "进入 BO5 淘汰赛" ) def _shop_tier(state: dict[str, Any]) -> int: round_number = int(state["round_number"]) total_rounds = max(1, len(state.get("round_plan", []))) if total_rounds == 1: return 5 return min(5, 1 + (round_number - 1) * 5 // total_rounds) def _new_unit(card_id: str, copies: int = 1) -> dict[str, Any]: return { "card_id": card_id, "copies": copies, "star": 3 if copies >= 9 else 2 if copies >= 3 else 1, } def _normalize_unit(unit: dict[str, Any]) -> None: copies = int(unit.get("copies", 1)) unit["copies"] = copies unit["star"] = 3 if copies >= 9 else 2 if copies >= 3 else 1 def _unit_power(unit: dict[str, Any]) -> float: card = CARD_BY_ID[str(unit["card_id"])] base = ( card["firepower"] * POWER_FIREPOWER_WEIGHT + card["stability"] * POWER_STABILITY_WEIGHT + card["clutch"] * POWER_CLUTCH_WEIGHT ) if card["role"] == "指挥": base += ( max(0, card["leadership"] - POWER_LEADERSHIP_THRESHOLD) * POWER_LEADERSHIP_SCALE ) multiplier = STAR_MULTIPLIERS[int(unit["star"])] return base * multiplier def _synergy_values( counts: dict[str, int], rules: dict[str, Any], ) -> list[tuple[int, int, str]]: values: list[tuple[int, int, str]] = [] thresholds = list(rules["thresholds"]) for name, count in counts.items(): bonus = 0 for rule in thresholds: if count >= int(rule["min_count"]): bonus = int(rule["bonus"]) break if bonus: values.append((bonus, count, name)) values.sort(reverse=True) return values[: int(rules["max_sources"])] def lineup_power(player: dict[str, Any]) -> dict[str, Any]: board = player.get("board", []) if len(board) != STARTER_COUNT: raise ValueError("比赛阵容必须正好有 5 名首发") base = sum(_unit_power(unit) for unit in board) teams: dict[str, int] = {} nations: dict[str, int] = {} axes = {"火力": 0.0, "稳定": 0.0, "残局": 0.0} roles = {"步枪手": 0, "狙击手": 0, "指挥": 0} for unit in board: card = CARD_BY_ID[str(unit["card_id"])] multiplier = STAR_MULTIPLIERS[int(unit["star"])] axes["火力"] += float(card["firepower"]) * multiplier axes["稳定"] += float(card["stability"]) * multiplier axes["残局"] += float(card["clutch"]) * multiplier roles[str(card["role"])] += 1 nations[card["nation"]] = nations.get(card["nation"], 0) + 1 for team in card["teams"]: teams[team] = teams.get(team, 0) + 1 team_values = _synergy_values(teams, TEAM_SYNERGY_RULE) team_bonus = sum(item[0] for item in team_values) nation_values = _synergy_values(nations, NATION_SYNERGY_RULE) nation_bonus = sum(item[0] for item in nation_values) coach_id = str(player.get("coach_id", "")) coach = COACH_BY_ID.get(coach_id) if coach is not None: axes["火力"] += float(coach["firepower"]) axes["稳定"] += float(coach["stability"]) axes["残局"] += float(coach["clutch"]) coach_bonus = int(coach["bonus"]) coach_label = f"{coach['name']}+{coach_bonus}%" else: coach_level = int(player.get("coach_level") or 1) coach_bonus = COACH_BONUSES.get(coach_level, 0) coach_label = f"教练+{coach_bonus}%" total = base * (100 + team_bonus + nation_bonus + coach_bonus) / 100 if player.get("is_ai"): total *= AI_POWER_MULTIPLIER labels = [ *(f"{name}{count}" for _, count, name in team_values[:2]), *(f"{name}{count}" for _, count, name in nation_values[:1]), coach_label, ] return { "power": round(total, 1), "base": round(base, 1), "team_bonus": team_bonus, "nation_bonus": nation_bonus, "coach_bonus": coach_bonus, "synergies": labels, "axes": {name: round(value, 1) for name, value in axes.items()}, "roles": roles, } def matchup_modifier( player_a: dict[str, Any], player_b: dict[str, Any], ) -> dict[str, Any]: info_a = lineup_power(player_a) info_b = lineup_power(player_b) axes_a = dict(info_a["axes"]) axes_b = dict(info_b["axes"]) tactic_a = str(player_a.get("tactic", "稳定")) tactic_b = str(player_b.get("tactic", "稳定")) axes_a[tactic_a] *= TACTIC_AXIS_MULTIPLIER axes_b[tactic_b] *= TACTIC_AXIS_MULTIPLIER def pressure( own: dict[str, float], opponent: dict[str, float], ) -> float: return sum( max(0.0, own[strong] - opponent[weak]) for strong, weak in ATTRIBUTE_COUNTERS ) axis_net = pressure(axes_a, axes_b) - pressure(axes_b, axes_a) axis_percent = max( -TACTIC_AXIS_PERCENT_CAP, min(TACTIC_AXIS_PERCENT_CAP, axis_net / TACTIC_AXIS_PERCENT_DIVISOR), ) roles_a = info_a["roles"] roles_b = info_b["roles"] role_edge_a = sum( int(roles_a[strong]) * int(roles_b[weak]) for strong, weak in ROLE_COUNTERS.items() ) role_edge_b = sum( int(roles_b[strong]) * int(roles_a[weak]) for strong, weak in ROLE_COUNTERS.items() ) role_percent = max( -TACTIC_ROLE_PERCENT_CAP, min( TACTIC_ROLE_PERCENT_CAP, (role_edge_a - role_edge_b) * TACTIC_ROLE_PERCENT_SCALE, ), ) tactic_percent = 0.0 if TACTIC_COUNTERS[tactic_a] == tactic_b: tactic_percent = TACTIC_COUNTER_PERCENT elif TACTIC_COUNTERS[tactic_b] == tactic_a: tactic_percent = -TACTIC_COUNTER_PERCENT total = max( -TACTIC_TOTAL_PERCENT_CAP, min(TACTIC_TOTAL_PERCENT_CAP, axis_percent + role_percent + tactic_percent), ) return { "a_total_percent": round(total, 2), "b_total_percent": round(-total, 2), "axis_percent": round(axis_percent, 2), "role_percent": round(role_percent, 2), "tactic_percent": round(tactic_percent, 2), "tactic_a": tactic_a, "tactic_b": tactic_b, } def _draw_shop(state: dict[str, Any], seed: str) -> list[str]: tier = _shop_tier(state) odds = SHOP_ODDS[tier] selected: list[str] = [] for slot in range(SHOP_SIZE): available_costs = [ cost for cost, weight in odds.items() if weight > 0 and any( card["cost"] == cost and int(state["pool"].get(card["id"], 0)) > 0 and card["id"] not in selected for card in PLAYER_POOL ) ] if not available_costs: break total_weight = sum(odds[cost] for cost in available_costs) roll = _hash_int(f"{seed}|cost|{slot}") % total_weight chosen_cost = available_costs[-1] for cost in available_costs: if roll < odds[cost]: chosen_cost = cost break roll -= odds[cost] candidates = [ card["id"] for card in PLAYER_POOL if card["cost"] == chosen_cost and int(state["pool"].get(card["id"], 0)) > 0 and card["id"] not in selected ] candidates.sort( key=lambda card_id: _hash_int(f"{seed}|card|{slot}|{card_id}") ) selected.append(candidates[0]) return selected def _story( state: dict[str, Any], story_type: str, text: str, facts: dict[str, Any] | None = None, ) -> dict[str, Any]: state["story_sequence"] = int(state.get("story_sequence", 0)) + 1 story_text = str(text).strip() item = { "story_id": f"story_{state['story_sequence']:04d}", "round_number": int(state.get("round_number", 0)), "phase": str(state.get("phase", "registration")), "story_type": story_type, "text": story_text, "facts": facts or {}, } state.setdefault("stories", []).append(item) return item def _battle_record( state: dict[str, Any], *, battle_type: str, player_a: str, player_b: str, result: dict[str, Any], ) -> dict[str, Any]: state["battle_sequence"] = int(state.get("battle_sequence", 0)) + 1 item = { "battle_id": f"battle_{state['battle_sequence']:04d}", "round_number": int(state["round_number"]), "phase": str(state["phase"]), "battle_type": battle_type, "player_a": player_a, "player_b": player_b, "result": result, } state.setdefault("battles", []).append(item) return item def _result( state: dict[str, Any] | None, response: str, *, stories: list[dict[str, Any]] | None = None, battles: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: return { "state": state, "response": response, "stories": stories or [], "battles": battles or [], } def _participant( person_key: str, display_name: str, *, is_ai: bool = False, ) -> dict[str, Any]: return { "person_key": person_key, "display_name": display_name, "hp": STARTING_HP, "gold": STARTING_GOLD, "team_name": f"Team {display_name}" if not is_ai else display_name, "coach_id": "", "coach_level": 0, "coach_bonus": 0, "coach_style": "", "board": [], "bench": [], "shop": [], "shop_locked": False, "ready": False, "wins": 0, "losses": 0, "byes": 0, "streak": 0, "opponents": [], "placement": None, "tactic": "稳定", "is_ai": is_ai, } def _assign_starters(state: dict[str, Any], player: dict[str, Any]) -> None: candidates = [ card["id"] for card in PLAYER_POOL if card["cost"] == 1 and int(state["pool"].get(card["id"], 0)) > 0 ] candidates.sort( key=lambda card_id: _hash_int( f"{state['run_id']}|starter|{player['person_key']}|{card_id}" ) ) if len(candidates) < STARTER_COUNT: raise ValueError("低费选手池不足,无法生成完整首发") for card_id in candidates[:STARTER_COUNT]: state["pool"][card_id] -= 1 player["board"].append(_new_unit(card_id)) def _player_prefix(state: dict[str, Any], player: dict[str, Any]) -> str: return f"{player['display_name']} #{state['season_number']}:" def _card_line(slot: int, card_id: str | None) -> str: if card_id is None: return f"{slot}. 已购买" card = CARD_BY_ID[card_id] history = [team for team in card["teams"] if team != card["team"]] history_text = "/".join(history) if history else "无" leadership = ( f" 指挥{card['leadership']}" if card["role"] == "指挥" else "" ) return ( f"{slot}. {card['name']}|{card['cost']}费 {card['role']}|" f"{card['nation']}|{card['team']}(历史 {history_text})|" f"火{card['firepower']} 稳{card['stability']} 残{card['clutch']}" f"{leadership}" ) def _shop_text(state: dict[str, Any], player: dict[str, Any]) -> str: phase_name = str(_current_stage(state)["name"]) power = lineup_power(player) lock = "已锁" if player["shop_locked"] else "未锁" lines = [ ( f"{_player_prefix(state, player)}第 {state['round_number']} 回合" f" {phase_name};摩拉 {player['gold']};生命 {player['hp']};" f"教练 {player['coach_level']} 级;战术{player['tactic']};" f"阵容 {power['power']:.1f};商店{lock}" ) ] lines.extend( _card_line(index, card_id) for index, card_id in enumerate(player["shop"], start=1) ) lines.append("操作:/选1~/选6;/战术 火力|稳定|残局;/阵容;/结束回合") return "\n".join(lines) def _roster_entries(player: dict[str, Any]) -> list[tuple[str, int, dict[str, Any]]]: return [ *(("首发", index, unit) for index, unit in enumerate(player["board"], 1)), *(("备战", index, unit) for index, unit in enumerate(player["bench"], 6)), ] def _lineup_text(state: dict[str, Any], player: dict[str, Any]) -> str: power = lineup_power(player) lines = [ ( f"{_player_prefix(state, player)}固定 5 人首发 + 教练;" f"战力 {power['power']:.1f}(基础 {power['base']:.1f});" f"战术 {player['tactic']};羁绊 {'、'.join(power['synergies'])};" f"属性 火{power['axes']['火力']:.0f}/稳{power['axes']['稳定']:.0f}/" f"残{power['axes']['残局']:.0f}" ) ] for scope, index, unit in _roster_entries(player): card = CARD_BY_ID[str(unit["card_id"])] lines.append( f"{index}. [{scope}] {card['name']} {unit['star']}星×{unit['copies']} " f"{card['cost']}费 {card['role']} {card['team']}/{card['nation']} " f"单卡{_unit_power(unit):.1f}" ) if not player["bench"]: lines.append("备战席为空") lines.append("替换:/上阵 备战编号 [首发位];出售仅限备战席:/出售 编号") return "\n".join(lines) def _find_unit( player: dict[str, Any], index: int, ) -> tuple[str, int, dict[str, Any]] | None: for scope, display_index, unit in _roster_entries(player): if display_index == index: internal = display_index - 1 if scope == "首发" else display_index - 6 return scope, internal, unit return None def _merge_purchase(player: dict[str, Any], card_id: str) -> tuple[int, bool]: for unit in [*player["board"], *player["bench"]]: if unit["card_id"] == card_id: old_star = int(unit["star"]) unit["copies"] = int(unit["copies"]) + 1 _normalize_unit(unit) return int(unit["star"]), int(unit["star"]) > old_star if len(player["bench"]) >= BENCH_LIMIT: return 0, False player["bench"].append(_new_unit(card_id)) return 1, False def _ai_opponent(state: dict[str, Any], ai_key: str) -> str | None: stage = _current_stage(state) pairs = [ (str(pair[0]), str(pair[1])) for pair in stage.get("pairs", []) ] if stage.get("kind") == "semifinal" and not pairs: qualifiers = list(state.get("qualifiers") or _rankings(state)[:4]) if len(qualifiers) == 4: pairs = [ (qualifiers[0], qualifiers[3]), (qualifiers[1], qualifiers[2]), ] if stage.get("kind") == "final": finalists = list(state.get("finalists", [])) if len(finalists) != 2: finalists = list(state.get("qualifiers", []))[:2] if len(finalists) == 2: pairs = [(finalists[0], finalists[1])] for first, second in pairs: if first == ai_key: return second if second == ai_key: return first return None def _auto_manage_ai( state: dict[str, Any], ) -> list[dict[str, Any]]: stories: list[dict[str, Any]] = [] for ai_key in state["join_order"]: player = state["players"][ai_key] if not player.get("is_ai"): continue actions: list[str] = [] opponent_key = _ai_opponent(state, ai_key) if opponent_key is not None: opponent_tactic = str( state["players"][opponent_key].get("tactic", "稳定") ) player["tactic"] = next( tactic for tactic, target in TACTIC_COUNTERS.items() if target == opponent_tactic ) actions.append(f"战术{player['tactic']}") else: player["tactic"] = TACTICS[ (int(state["round_number"]) - 1) % len(TACTICS) ] actions.append(f"战术{player['tactic']}") level = int(player["coach_level"]) if level < 5: upgrade_cost = COACH_UPGRADE_COSTS[level] if int(player["gold"]) >= upgrade_cost + REFRESH_COST: player["gold"] -= upgrade_cost player["coach_level"] = level + 1 actions.append(f"教练{level + 1}级") candidates = [] owned = { str(unit["card_id"]) for unit in [*player["board"], *player["bench"]] } for slot, card_id in enumerate(player["shop"]): if card_id is None: continue card = CARD_BY_ID[str(card_id)] if ( int(card["cost"]) <= int(player["gold"]) and int(state["pool"].get(card_id, 0)) > 0 and (card_id in owned or len(player["bench"]) < BENCH_LIMIT) ): candidates.append( ( card_id in owned, int(card["cost"]), int(card["firepower"]) + int(card["stability"]) + int(card["clutch"]), -slot, slot, str(card_id), ) ) if candidates: *_, slot, card_id = max(candidates) card = CARD_BY_ID[card_id] player["gold"] -= int(card["cost"]) state["pool"][card_id] -= 1 star, _ = _merge_purchase(player, card_id) player["shop"][slot] = None actions.append(f"签下{card['name']}({star}星)") if player["bench"]: strongest_index = max( range(len(player["bench"])), key=lambda index: _unit_power(player["bench"][index]), ) weakest_index = min( range(len(player["board"])), key=lambda index: _unit_power(player["board"][index]), ) if ( _unit_power(player["bench"][strongest_index]) > _unit_power(player["board"][weakest_index]) ): incoming = player["bench"][strongest_index] outgoing = player["board"][weakest_index] player["board"][weakest_index] = incoming player["bench"][strongest_index] = outgoing actions.append( "换上" + CARD_BY_ID[str(incoming["card_id"])]["name"] ) text = ( f"{player['display_name']} 自动运营:{'、'.join(actions)};" f"摩拉 {player['gold']},五人首发战力 " f"{lineup_power(player)['power']:.1f}。" ) stories.append( _story( state, "ai_operation", text, { "person_key": ai_key, "actions": actions, "gold": player["gold"], "tactic": player["tactic"], }, ) ) return stories def _round_probability( adjusted_a: float, adjusted_b: float, seed: str, round_index: int, score_a: int, score_b: int, ) -> float: average = max(1.0, (adjusted_a + adjusted_b) / 2) power_edge = max( -ROUND_POWER_EDGE_CAP, min( ROUND_POWER_EDGE_CAP, (adjusted_a - adjusted_b) / average * ROUND_POWER_EDGE_SCALE, ), ) pressure = max( -ROUND_PRESSURE_CAP, min(ROUND_PRESSURE_CAP, (score_b - score_a) * ROUND_PRESSURE_SCALE), ) side_swing = ( ROUND_FIRST_HALF_SIDE_SWING if round_index <= BATTLE_MR_ROUNDS // 2 else ROUND_SECOND_HALF_SIDE_SWING ) if round_index > BATTLE_MR_ROUNDS: side_swing = 0.0 jitter = ( (_hash_int(f"{seed}|prob|{round_index}") % ROUND_JITTER_MOD) - ROUND_JITTER_CENTER ) / ROUND_JITTER_DIVISOR return max( ROUND_PROBABILITY_MIN, min( ROUND_PROBABILITY_MAX, ROUND_PROBABILITY_BASE + power_edge + pressure + side_swing + jitter, ), ) def _simulate_map( state: dict[str, Any], key_a: str, key_b: str, seed: str, map_index: int, adjusted_a: float, adjusted_b: float, ) -> dict[str, Any]: score_a = 0 score_b = 0 rounds: list[str] = [] current_key = "" current_run = 0 best_run_key = "" best_run = 0 def record_round(round_index: int) -> None: nonlocal score_a, score_b, current_key, current_run nonlocal best_run_key, best_run probability_a = _round_probability( adjusted_a, adjusted_b, seed, round_index, score_a, score_b, ) roll = (_hash_int(f"{seed}|round|{round_index}") % 10_000) / 10_000 winner_key = key_a if roll < probability_a else key_b if winner_key == key_a: score_a += 1 else: score_b += 1 rounds.append(winner_key) if winner_key == current_key: current_run += 1 else: current_key = winner_key current_run = 1 if current_run > best_run: best_run = current_run best_run_key = winner_key for round_index in range(1, BATTLE_MR_ROUNDS + 1): record_round(round_index) if score_a >= BATTLE_TARGET_SCORE or score_b >= BATTLE_TARGET_SCORE: break round_index = len(rounds) if ( score_a == BATTLE_OVERTIME_START_SCORE and score_b == BATTLE_OVERTIME_START_SCORE ): while ( max(score_a, score_b) < BATTLE_OVERTIME_TARGET_SCORE or abs(score_a - score_b) < BATTLE_OVERTIME_WIN_BY ): round_index += 1 record_round(round_index) if round_index > BATTLE_OVERTIME_SAFETY_CAP: break winner_key = key_a if score_a > score_b else key_b loser_key = key_b if winner_key == key_a else key_a winner_score = score_a if winner_key == key_a else score_b loser_score = score_b if winner_key == key_a else score_a overtime_rounds = max(0, len(rounds) - BATTLE_MR_ROUNDS) overtime_count = ( overtime_rounds + BATTLE_OVERTIME_SEGMENT_ROUNDS - 1 ) // BATTLE_OVERTIME_SEGMENT_ROUNDS if overtime_count: band = "加时险胜" else: band = next( str(item["label"]) for item in SCORE_BANDS if loser_score <= int(item["max_loser_score"]) ) map_name = MAP_POOL[(map_index - 1) % len(MAP_POOL)] winner_team = _team_name(state["players"][winner_key]) loser_team = _team_name(state["players"][loser_key]) best_run_team = ( _team_name(state["players"][best_run_key]) if best_run_key else winner_team ) opening_team = _team_name( state["players"][key_a if _hash_int(seed + "|open") % 2 == 0 else key_b] ) actor = _key_player_name(state, winner_key, map_index) pause = _pick_fragment("pauses", f"{seed}|pause") run_text = ( _pick_fragment( "runs", f"{seed}|run", team=best_run_team, count=best_run, ) if best_run >= 4 else pause ) overtime_story = "" if overtime_count: overtime_story = ( f"{map_name} 常规时间战成 15-15。{winner_team} 在加时第 " f"{overtime_count} 段抓住 {loser_team} 的转点空隙,以 " f"{winner_score}-{loser_score} 收下地图。" ) return { "map_index": map_index, "map_name": map_name, "winner_key": winner_key, "winner": state["players"][winner_key]["display_name"], "loser_key": loser_key, "loser": state["players"][loser_key]["display_name"], "score": f"{winner_score}-{loser_score}", "score_a": score_a, "score_b": score_b, "band": band, "overtime_count": overtime_count, "overtime_story": overtime_story, "rounds_played": len(rounds), "best_run_key": best_run_key, "best_run": best_run, "opening_text": _pick_fragment("openings", f"{seed}|opening", team=opening_team), "run_text": run_text, "pause_text": pause, "clutch_text": _pick_fragment("clutches", f"{seed}|clutch", player=actor), "economy_text": _pick_fragment("economy", f"{seed}|economy", team=loser_team), "closer_text": _pick_fragment("closers", f"{seed}|closer", team=winner_team), "adjusted_a": round(adjusted_a, 2), "adjusted_b": round(adjusted_b, 2), } def _battle( state: dict[str, Any], key_a: str, key_b: str, seed: str, *, best_of: int, ) -> dict[str, Any]: player_a = state["players"][key_a] player_b = state["players"][key_b] raw_a = lineup_power(player_a)["power"] raw_b = lineup_power(player_b)["power"] matchup = matchup_modifier(player_a, player_b) target_maps = best_of // 2 + 1 wins_a = 0 wins_b = 0 maps: list[dict[str, Any]] = [] for map_index in range(1, best_of + 1): fluct_a = _hash_int(f"{seed}|map|{map_index}|{key_a}") % 401 - 200 fluct_b = _hash_int(f"{seed}|map|{map_index}|{key_b}") % 401 - 200 adjusted_a = ( raw_a * (100 + float(matchup["a_total_percent"])) / 100 * (10_000 + fluct_a) / 10_000 ) adjusted_b = ( raw_b * (100 + float(matchup["b_total_percent"])) / 100 * (10_000 + fluct_b) / 10_000 ) item = _simulate_map( state, key_a, key_b, f"{seed}|map|{map_index}", map_index, adjusted_a, adjusted_b, ) winner_key = str(item["winner_key"]) if winner_key == key_a: wins_a += 1 else: wins_b += 1 item["fluctuation_a_bps"] = fluct_a item["fluctuation_b_bps"] = fluct_b maps.append(item) if wins_a == target_maps or wins_b == target_maps: break winner_key, loser_key = ( (key_a, key_b) if wins_a > wins_b else (key_b, key_a) ) winner_maps = max(wins_a, wins_b) loser_maps = min(wins_a, wins_b) sweep = loser_maps == 0 damage = ( BATTLE_BO5_SWEEP_DAMAGE if sweep and best_of == 5 else BATTLE_SWEEP_DAMAGE if sweep else BATTLE_NON_SWEEP_DAMAGE ) return { "winner_key": winner_key, "loser_key": loser_key, "player_a": player_a["display_name"], "player_b": player_b["display_name"], "winner": state["players"][winner_key]["display_name"], "loser": state["players"][loser_key]["display_name"], "best_of": best_of, "series_format": f"BO{best_of}", "score": f"{winner_maps}-{loser_maps}", "band": "横扫" if sweep else "鏖战", "damage": damage, "power_a": round(raw_a, 1), "power_b": round(raw_b, 1), "matchup": matchup, "maps": maps, } def _apply_match_result( state: dict[str, Any], key_a: str, key_b: str, result: dict[str, Any], *, group_stage: bool, ) -> None: winner_key = str(result["winner_key"]) loser_key = str(result["loser_key"]) winner = state["players"][winner_key] loser = state["players"][loser_key] loser["hp"] = max(0, int(loser["hp"]) - int(result["damage"])) winner["streak"] = max(1, int(winner["streak"]) + 1) loser["streak"] = min(-1, int(loser["streak"]) - 1) if group_stage: winner["wins"] = int(winner["wins"]) + 1 loser["losses"] = int(loser["losses"]) + 1 state.setdefault("group_results", []).append( { "winner_key": winner_key, "loser_key": loser_key, "score": str(result["score"]), } ) for owner, opponent in ((key_a, key_b), (key_b, key_a)): state["players"][owner].setdefault("opponents", []).append(opponent) def _rankings(state: dict[str, Any]) -> list[str]: keys = list(state["join_order"]) seed_order = { key: index for index, key in enumerate(state.get("seed_order", keys), start=1) } wins_by_key = { key: int(state["players"][key]["wins"]) for key in keys } def head_to_head_wins(key: str) -> int: tied = { other for other in keys if wins_by_key[other] == wins_by_key[key] } return sum( 1 for result in state.get("group_results", []) if result.get("winner_key") == key and result.get("loser_key") in tied ) keys.sort( key=lambda key: ( -wins_by_key[key], -head_to_head_wins(key), -lineup_power(state["players"][key])["power"], -int(state["players"][key]["hp"]), seed_order.get(key, 999), ) ) return keys def _battle_story(result: dict[str, Any]) -> str: map_scores = "、".join( f"{item['map_name']} {item['score']}" for item in result["maps"] ) matchup = result["matchup"] tactic_note = ( "战术克制明显" if abs(float(matchup["a_total_percent"])) >= 5.5 else "战术拉扯接近" ) return _host_text( f"{result['winner']} 在 {result['series_format']} 以 {result['score']} " f"击败 {result['loser']}({map_scores})。" f"战术面是 {matchup['tactic_a']} 对 {matchup['tactic_b']},{tactic_note}。" ) def _append_overtime_stories( state: dict[str, Any], result: dict[str, Any], stories: list[dict[str, Any]], ) -> None: for item in result["maps"]: if int(item["overtime_count"]) <= 0: continue stories.append( _story( state, "overtime", str(item["overtime_story"]), { "map_name": item["map_name"], "score": item["score"], "overtime_count": item["overtime_count"], "winner_key": item["winner_key"], "loser_key": item["loser_key"], }, ) ) def _settle_pve( state: dict[str, Any], event_id: str, ) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]: stage = _current_stage(state) event_name, target, win_gold, loss_gold = PVE_EVENTS[str(stage["phase"])] lines: list[str] = [] stories: list[dict[str, Any]] = [] battles: list[dict[str, Any]] = [] for key in state["join_order"]: player = state["players"][key] raw_power = lineup_power(player)["power"] fluctuation = _hash_int( f"{state['run_id']}|{state['round_number']}|{event_id}|{key}|pve" ) % 401 - 200 adjusted = raw_power * (10_000 + fluctuation) / 10_000 won = adjusted >= target reward = win_gold if won else loss_gold player["gold"] = int(player["gold"]) + reward result = { "winner_key": key if won else "event", "loser_key": "event" if won else key, "winner": player["display_name"] if won else "赛事活动", "loser": "赛事活动" if won else player["display_name"], "score": "达标" if won else "未达标", "band": "资源挑战", "damage": 0, "player_power": round(adjusted, 2), "target_power": target, "reward": reward, "fluctuation_bps": fluctuation, } battles.append( _battle_record( state, battle_type="pve", player_a=key, player_b="event", result=result, ) ) text = ( f"{player['display_name']} {'完成' if won else '未完成'}" f"{event_name}目标 {adjusted:.1f}/{target},获得 {reward} 摩拉。" ) stories.append(_story(state, "pve_result", text, result)) lines.append(_host_text(text)) return lines, stories, battles def _settle_group( state: dict[str, Any], event_id: str, ) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]: stage = _current_stage(state) pairs = [ (str(pair[0]), str(pair[1])) for pair in stage.get("pairs", []) ] paired = {key for pair in pairs for key in pair} bye = next( (key for key in state["join_order"] if key not in paired), None, ) lines: list[str] = [] stories: list[dict[str, Any]] = [] battles: list[dict[str, Any]] = [] if bye is not None: player = state["players"][bye] player["byes"] = int(player["byes"]) + 1 text = f"{player['display_name']} 本轮轮空休整,不增加小组胜场。" stories.append(_story(state, "group_bye", text, {"person_key": bye})) lines.append(text) for index, (key_a, key_b) in enumerate(pairs, start=1): result = _battle( state, key_a, key_b, f"{state['run_id']}|group|{stage['day']}|{event_id}|{index}", best_of=MATCH_BEST_OF["group"], ) _apply_match_result( state, key_a, key_b, result, group_stage=True, ) battles.append( _battle_record( state, battle_type="group_bo3", player_a=key_a, player_b=key_b, result=result, ) ) text = _battle_story(result) stories.append(_story(state, "group_result", text, result)) _append_overtime_stories(state, result, stories) lines.append(text) lines.extend( _host_text(str(item["overtime_story"])) for item in result["maps"] if int(item["overtime_count"]) > 0 ) if stage.get("last_group_day"): ranking = _rankings(state) qualifier_count = 2 if len(ranking) == 3 else 4 state["qualifiers"] = ranking[:qualifier_count] names = "、".join( state["players"][key]["display_name"] for key in state["qualifiers"] ) text = f"小组单循环结束,晋级淘汰赛:{names}。" stories.append( _story( state, "group_complete", text, { "ranking": ranking, "qualifiers": list(state["qualifiers"]), }, ) ) lines.append(text) return lines, stories, battles def _settle_semifinal( state: dict[str, Any], event_id: str, ) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]: stage = _current_stage(state) if stage.get("pairing") == "seed": pairs = [ (str(pair[0]), str(pair[1])) for pair in stage.get("pairs", []) ] state["qualifiers"] = list(state["seed_order"]) else: qualifiers = list(state.get("qualifiers") or _rankings(state)[:4]) pairs = [ (qualifiers[0], qualifiers[3]), (qualifiers[1], qualifiers[2]), ] lines: list[str] = [] stories: list[dict[str, Any]] = [] battles: list[dict[str, Any]] = [] finalists: list[str] = [] for index, (key_a, key_b) in enumerate(pairs, start=1): result = _battle( state, key_a, key_b, f"{state['run_id']}|semifinal|{event_id}|{index}", best_of=MATCH_BEST_OF["semifinal"], ) _apply_match_result( state, key_a, key_b, result, group_stage=False, ) finalists.append(str(result["winner_key"])) battles.append( _battle_record( state, battle_type="semifinal_bo5", player_a=key_a, player_b=key_b, result=result, ) ) text = _battle_story(result) stories.append(_story(state, "semifinal_result", text, result)) _append_overtime_stories(state, result, stories) lines.append(text) lines.extend( _host_text(str(item["overtime_story"])) for item in result["maps"] if int(item["overtime_count"]) > 0 ) state["finalists"] = finalists return lines, stories, battles def _settle_final( state: dict[str, Any], event_id: str, ) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]: finalists = list(state.get("finalists", [])) if len(finalists) != 2: if len(state["join_order"]) == 2: finalists = list(state["seed_order"]) else: finalists = list(state.get("qualifiers") or _rankings(state)[:2])[:2] key_a, key_b = finalists result = _battle( state, key_a, key_b, f"{state['run_id']}|final|{event_id}", best_of=MATCH_BEST_OF["final"], ) _apply_match_result( state, key_a, key_b, result, group_stage=False, ) champion_key = str(result["winner_key"]) state["champion_key"] = champion_key state["status"] = "completed" battles = [ _battle_record( state, battle_type="final_bo5", player_a=key_a, player_b=key_b, result=result, ) ] stories = [_story(state, "final_result", _battle_story(result), result)] _append_overtime_stories(state, result, stories) champion = state["players"][champion_key] power = lineup_power(champion) title = state["event_title"] record = ( f"小组赛 {champion['wins']}-{champion['losses']}" if state.get("group_results") else f"报名种子 #{champion.get('seed', 0)}" ) text = ( f"{champion['display_name']} 夺得{title}冠军;最终阵容战力 " f"{power['power']:.1f},{record}。" ) stories.append( _story( state, "champion", text, { "champion_key": champion_key, "power": power["power"], "wins": champion["wins"], "losses": champion["losses"], }, ) ) overtime_lines = [ _host_text(str(item["overtime_story"])) for item in result["maps"] if int(item["overtime_count"]) > 0 ] return [ _battle_story(result), *overtime_lines, _host_text(text), ], stories, battles def _settle_round( state: dict[str, Any], event_id: str, ) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]: ai_stories = _auto_manage_ai(state) stage = _current_stage(state) kind = str(stage["kind"]) if kind == "pve": lines, stories, battles = _settle_pve(state, event_id) elif kind == "group": lines, stories, battles = _settle_group(state, event_id) elif kind == "semifinal": lines, stories, battles = _settle_semifinal(state, event_id) elif kind == "final": lines, stories, battles = _settle_final(state, event_id) else: raise ValueError("赛事轮次无效") stories = [*ai_stories, *stories] round_intro = _host_text( f"第 {state['round_number']} 回合{stage['name']}结算开始。" ) lines = [ round_intro, *(str(item["text"]) for item in ai_stories), *lines, ] if state["status"] == "completed": return lines, stories, battles for key in state["join_order"]: player = state["players"][key] income = ROUND_INCOME_BASE + min( ROUND_INTEREST_CAP, int(player["gold"]) // ROUND_INTEREST_DIVISOR, ) player["gold"] = int(player["gold"]) + income player["ready"] = False state["round_number"] = int(state["round_number"]) + 1 next_stage = _current_stage(state) state["phase"] = str(next_stage["phase"]) _reset_preparation(state) public_shop = _draw_shop( state, f"{state['run_id']}|round|{state['round_number']}|public", ) state["public_shop"] = public_shop for key in state["join_order"]: player = state["players"][key] if not player["shop_locked"]: player["shop"] = list(public_shop) next_text = _host_text( f"第 {state['round_number']} 回合{next_stage['name']}开始;" "全员获得基础收入和利息。" ) stories.append(_story(state, "round_start", next_text, {"shop": public_shop})) lines.append(next_text) prompt = _preparation_prompt(state) stories.append( _story( state, "preparation_prompt", prompt, { "person_key": _current_preparer(state), "order": list(state["preparation_order"]), }, ) ) lines.append(prompt) return lines, stories, battles def _schedule_text(state: dict[str, Any]) -> str: current = int(state.get("round_number", 0)) if state["status"] == "registration": joined = "、".join( state["players"][key]["display_name"] for key in state["join_order"] ) return ( f"{state['event_title']} 报名中;{len(state['join_order'])}/{MAX_PLAYERS} 人:" f"{joined}。主持人发送 /自走棋开赛" ) ranking = _rankings(state) stage = _current_stage(state) stage_label = ( "已结束" if state["status"] == "completed" else f"第 {current} 回合 {stage['name']}" ) lines = [ f"{state['event_title']}|{stage_label}" ] for index, key in enumerate(ranking, start=1): player = state["players"][key] marker = " *" if key in state.get("qualifiers", []) else "" lines.append( f"{index}. {player['display_name']} {player['wins']}-{player['losses']} " f"HP{player['hp']} 战力{lineup_power(player)['power']:.1f} " f"战术{player.get('tactic', '稳定')}{marker}" ) plan_text = " > ".join( f"{index}{item['name']}" for index, item in enumerate(state.get("round_plan", []), start=1) ) lines.append("赛程:" + plan_text) return "\n".join(lines) def _archive_text(state: dict[str, Any], page: int) -> str: stories = list(state.get("stories", [])) page_size = 7 pages = max(1, (len(stories) + page_size - 1) // page_size) page = min(max(1, page), pages) start = (page - 1) * page_size selected = stories[start : start + page_size] lines = [ f"{state['event_title']} 档案 {page}/{pages};共 {len(stories)} 段剧情" ] for item in selected: lines.append( f"R{item['round_number']} {item['story_type']}:{item['text']}" ) if not selected: lines.append("暂无剧情") return "\n".join(lines) def _parse_indices(value: str) -> list[int]: value = value.strip() if not value: return [] tokens = re.findall(r"\d{1,2}", value) return [int(token) for token in tokens] def parse_autochess_action(question: str) -> tuple[str, str]: raw = question.lstrip() if raw.startswith("/"): raw = raw[1:].lstrip() folded = raw.casefold() for prefix in ("cs自走棋", "autochess", "自走棋"): if folded.startswith(prefix.casefold()): body = raw[len(prefix) :].strip() compact = re.sub(r"\s+", "", body.casefold()) if not compact: return "status", "" actions = ( ("强制结算", "force_settle"), ("结束回合", "ready"), ("升级教练", "coach"), ("加ai战队", "add_ai"), ("当前状态", "status"), ("开始", "create"), ("报名", "join"), ("开赛", "launch"), ("商店", "status"), ("状态", "status"), ("刷新", "refresh"), ("锁定", "lock"), ("战术", "tactic"), ("阵容", "lineup"), ("赛程", "schedule"), ("结算", "settle"), ("档案", "archive"), ("取消", "cancel"), ("start", "create"), ("join", "join"), ("launch", "launch"), ("addai", "add_ai"), ("shop", "status"), ("status", "status"), ("refresh", "refresh"), ("lock", "lock"), ("tactic", "tactic"), ("coach", "coach"), ("lineup", "lineup"), ("schedule", "schedule"), ("ready", "ready"), ("settle", "settle"), ("archive", "archive"), ("cancel", "cancel"), ) for token, action in actions: if compact.startswith(token): return action, compact[len(token) :] if compact.startswith("选") or compact.startswith("buy"): token = "选" if compact.startswith("选") else "buy" return "buy", compact[len(token) :] for token, action in (("上阵", "field"), ("下阵", "bench"), ("出售", "sell")): if compact.startswith(token): return action, body[len(token) :].strip() return "unknown", body direct = ( ("升级教练", "coach"), ("结束回合", "ready"), ("刷新", "refresh"), ("锁定", "lock"), ("战术", "tactic"), ("阵容", "lineup"), ("赛程", "schedule"), ("上阵", "field"), ("下阵", "bench"), ("出售", "sell"), ("选", "buy"), ) for token, action in direct: if folded.startswith(token.casefold()): return action, raw[len(token) :].strip() return "unknown", raw def _coach_line(slot: int, coach_id: str) -> str: coach = COACH_BY_ID[coach_id] return ( f"{slot}. {coach['name']}|{coach['level']}级教练|{coach['style']}|" f"火{coach['firepower']} 稳{coach['stability']} 残{coach['clutch']}|" f"全队+{coach['bonus']}%" ) def _team_name(player: dict[str, Any]) -> str: if player.get("is_ai"): return str(player["display_name"]) return f"Team {player['display_name']}" def _seed_lines(state: dict[str, Any]) -> str: return "、".join( f"{state['players'][key]['seed']}号{state['players'][key]['display_name']}" for key in state["seed_order"] ) def _draft_order(state: dict[str, Any], round_number: int) -> list[str]: order = list(state["seed_order"]) if round_number == 2: order.reverse() return order def _draft_candidate_cards(state: dict[str, Any], round_number: int) -> list[str]: cards = [ card["id"] for card in PLAYER_POOL if int(state["pool"].get(card["id"], 0)) > 0 ] cards.sort( key=lambda card_id: _hash_int( f"{state['run_id']}|draft|{round_number}|{card_id}" ) ) selected: list[str] = [] preferred_costs = ( DRAFT_ROUND_TWO_PREFERRED_COSTS if round_number == 2 else DRAFT_LATER_ROUND_PREFERRED_COSTS ) for cost in preferred_costs: for card_id in cards: if ( card_id not in selected and CARD_BY_ID[card_id]["cost"] == cost and len(selected) < DRAFT_PLAYER_CHOICE_COUNT ): selected.append(card_id) if len(selected) >= DRAFT_PLAYER_CHOICE_COUNT: break for card_id in cards: if len(selected) >= DRAFT_PLAYER_CHOICE_COUNT: break if card_id not in selected: selected.append(card_id) return selected[:DRAFT_PLAYER_CHOICE_COUNT] def _start_draft_round(state: dict[str, Any], round_number: int) -> None: draft_type = "coach" if round_number == DRAFT_COACH_ROUND else "player" candidates = ( [coach["id"] for coach in COACH_POOL[:DRAFT_COACH_CHOICE_COUNT]] if draft_type == "coach" else _draft_candidate_cards(state, round_number) ) state["phase"] = f"draft_{draft_type}_{round_number}" state["draft"] = { "round": round_number, "type": draft_type, "order": _draft_order(state, round_number), "cursor": 0, "candidates": candidates, } def _draft_round_title(draft: dict[str, Any]) -> str: number = int(draft["round"]) return ( "第一轮选秀,本轮选教练" if number == DRAFT_COACH_ROUND else f"第 {number} 轮选秀,本轮选选手" ) def _draft_board_part(state: dict[str, Any]) -> str: draft = state["draft"] order_names = "、".join( state["players"][key]["display_name"] for key in draft["order"] ) lines = [ f"欢迎来到{_draft_round_title(draft)}。", f"选择顺序:{order_names}", ] if draft["type"] == "coach": lines.extend( _coach_line(index, coach_id) for index, coach_id in enumerate(draft["candidates"], start=1) ) lines.append(f"操作:轮到你时发送 /选1~/选{DRAFT_COACH_CHOICE_COUNT}。") else: lines.extend( _card_line(index, card_id) for index, card_id in enumerate(draft["candidates"], start=1) ) lines.append("操作:轮到你时发送 /选1~/选6。") return "\n".join(lines) def _draft_prompt_part(state: dict[str, Any]) -> str: draft = state["draft"] cursor = int(draft.get("cursor", 0)) order = list(draft.get("order", [])) if cursor >= len(order): return "本轮选秀已完成。" key = str(order[cursor]) name = state["players"][key]["display_name"] command = ( f"/选1~/选{DRAFT_COACH_CHOICE_COUNT}" if draft["type"] == "coach" else f"/选1~/选{DRAFT_PLAYER_CHOICE_COUNT}" ) return f"现在轮到 @{name} 做出选择,使用 {command}。" def _add_unit_to_roster( player: dict[str, Any], card_id: str, *, copies: int = 1, ) -> None: unit = _new_unit(card_id, copies=copies) if len(player["board"]) < STARTER_COUNT: player["board"].append(unit) else: player["bench"].append(unit) def _apply_draft_choice( state: dict[str, Any], person_key: str, slot: int, ) -> str: draft = state["draft"] candidates = list(draft["candidates"]) player = state["players"][person_key] if not 1 <= slot <= len(candidates): raise ValueError("选秀编号无效") choice_id = str(candidates[slot - 1]) if draft["type"] == "coach": coach = COACH_BY_ID[choice_id] player["coach_id"] = choice_id player["coach_level"] = int(coach["level"]) player["coach_bonus"] = int(coach["bonus"]) player["coach_style"] = str(coach["style"]) text = ( f"{player['display_name']} 选择了教练 {coach['name']}!" f"{coach['level']}级,{coach['style']},全队+{coach['bonus']}%。" ) else: card = CARD_BY_ID[choice_id] draft_copies = DRAFT_PLAYER_COPY_COUNT if int(state["pool"].get(choice_id, 0)) < draft_copies: raise ValueError("这名选手已经被选走") state["pool"][choice_id] -= draft_copies _add_unit_to_roster(player, choice_id, copies=draft_copies) text = ( f"{player['display_name']} 选择了 {card['name']}!" f"{card['cost']}费 {card['role']},{card['team']}/{card['nation']}," "以 2 星素材加入阵容。" ) candidates.pop(slot - 1) draft["candidates"] = candidates draft["cursor"] = int(draft.get("cursor", 0)) + 1 return text def _auto_pick_slot(state: dict[str, Any], ai_key: str) -> int: draft = state["draft"] candidates = list(draft["candidates"]) if draft["type"] == "coach": values = [ ( int(COACH_BY_ID[str(coach_id)]["bonus"]), -int(COACH_BY_ID[str(coach_id)]["level"]), index, ) for index, coach_id in enumerate(candidates, start=1) ] return min(values)[2] values = [] for index, card_id in enumerate(candidates, start=1): card = CARD_BY_ID[str(card_id)] values.append( ( int(card["cost"]), int(card["firepower"]) + int(card["stability"]) + int(card["clutch"]), _hash_int(f"{state['run_id']}|ai|{ai_key}|{card_id}"), index, ) ) return min(values)[3] def _auto_advance_draft(state: dict[str, Any]) -> list[str]: parts: list[str] = [] while state.get("phase", "").startswith("draft_"): draft = state["draft"] order = list(draft["order"]) cursor = int(draft.get("cursor", 0)) if cursor >= len(order): break key = str(order[cursor]) if not state["players"][key].get("is_ai"): break slot = _auto_pick_slot(state, key) parts.append(_apply_draft_choice(state, key, slot)) return parts def _finish_draft_round_if_needed(state: dict[str, Any]) -> list[str]: parts: list[str] = [] while state.get("phase", "").startswith("draft_"): draft = state["draft"] if int(draft.get("cursor", 0)) < len(draft.get("order", [])): break round_number = int(draft["round"]) if round_number < DRAFT_ROUNDS: _start_draft_round(state, round_number + 1) parts.append(_draft_board_part(state)) parts.extend(_auto_advance_draft(state)) continue _ensure_full_rosters(state) state["phase"] = "lineup_prep" state.pop("draft", None) _reset_ready(state, _human_keys(state)) parts.append( "三轮选秀结束。每队已经整理为 5 名首发、1 名教练和 3 个备战席。" ) parts.append( "请各位玩家使用 /阵容 查看队伍,使用 /上阵 备战编号 [首发位] 调整首发;准备完成后发送 /结束回合。" ) break return parts def _ensure_full_rosters(state: dict[str, Any]) -> None: for key in state["join_order"]: player = state["players"][key] if not player.get("coach_id"): fallback = COACH_POOL[-1] if player.get("is_ai") else COACH_POOL[0] player["coach_id"] = fallback["id"] player["coach_level"] = int(fallback["level"]) player["coach_bonus"] = int(fallback["bonus"]) player["coach_style"] = str(fallback["style"]) owned = { str(unit["card_id"]) for unit in [*player.get("board", []), *player.get("bench", [])] } target_total = STARTER_COUNT + BENCH_LIMIT while len(player["board"]) + len(player["bench"]) < target_total: candidates = [ card["id"] for card in PLAYER_POOL if card["id"] not in owned and int(state["pool"].get(card["id"], 0)) > 0 ] candidates.sort( key=lambda card_id: ( CARD_BY_ID[card_id]["cost"] if player.get("is_ai") else abs(int(CARD_BY_ID[card_id]["cost"]) - 2), int(CARD_BY_ID[card_id]["cost"]), _hash_int(f"{state['run_id']}|fill|{key}|{card_id}"), ) ) if not candidates: break card_id = candidates[0] state["pool"][card_id] -= 1 owned.add(card_id) _add_unit_to_roster(player, card_id) units = [*player.get("board", []), *player.get("bench", [])] units.sort(key=_unit_power, reverse=True) player["board"] = units[:STARTER_COUNT] player["bench"] = units[STARTER_COUNT : STARTER_COUNT + BENCH_LIMIT] player["shop"] = _draw_shop( state, f"{state['run_id']}|lineup|{key}", ) def _reset_ready(state: dict[str, Any], keys: list[str]) -> None: ready_keys = set(keys) state["ready_keys"] = list(keys) for key in state["join_order"]: player = state["players"][key] player["ready"] = bool(player.get("is_ai") or key not in ready_keys) def _ready_waiting(state: dict[str, Any]) -> list[str]: return [ key for key in state.get("ready_keys", []) if not state["players"][key].get("ready") ] def _team_power_line(state: dict[str, Any], key: str) -> str: player = state["players"][key] power = lineup_power(player) coach = COACH_BY_ID.get(str(player.get("coach_id", ""))) coach_text = ( f"{coach['name']} {coach['level']}级" if coach is not None else f"{player.get('coach_level', 1)}级" ) return ( f"{player['display_name']} #{state['season_number']}:摩拉 {player['gold']};" f"生命 {player['hp']};教练 {coach_text};战术{player['tactic']};" f"阵容 {power['power']:.1f};商店{'已锁' if player['shop_locked'] else '未锁'}" ) def _full_lineup_text(state: dict[str, Any], player: dict[str, Any]) -> str: if len(player.get("board", [])) != STARTER_COUNT: drafted = [ CARD_BY_ID[str(unit["card_id"])]["name"] for unit in [*player.get("board", []), *player.get("bench", [])] ] coach = COACH_BY_ID.get(str(player.get("coach_id", ""))) return "\n".join( [ f"{player['display_name']} #{state['season_number']}:选秀中阵容", f"教练:{coach['name'] if coach else '未选择'}", "已选选手:" + ("、".join(drafted) if drafted else "暂无"), ] ) return _lineup_text(state, player) def _shop_status_text(state: dict[str, Any], player: dict[str, Any]) -> str: if len(player.get("board", [])) != STARTER_COUNT: return _full_lineup_text(state, player) return _team_power_line(state, player) + "\n" + "\n".join( _card_line(index, card_id) for index, card_id in enumerate(player.get("shop", []), start=1) ) + "\n操作:/选1~/选6;/刷新;/锁定;/阵容;/战术 火力|稳定|残局;/结束回合" def _training_schedule(state: dict[str, Any]) -> list[dict[str, Any]]: rounds: list[dict[str, Any]] = [] keys = list(state["seed_order"]) for round_index, pairs in enumerate(_round_robin_rounds(keys), start=1): paired = {key for pair in pairs for key in pair} bye = next((key for key in keys if key not in paired), "") rounds.append( { "round": round_index, "pairs": [[str(pair[0]), str(pair[1])] for pair in pairs], "bye": bye, } ) return rounds def _ensure_training_schedule(state: dict[str, Any]) -> None: if state.get("training_rounds"): return state["training_rounds"] = _training_schedule(state) state["training_round_cursor"] = 0 def _training_pairing_line(state: dict[str, Any], round_item: dict[str, Any]) -> str: rendered = [ f"{_team_name(state['players'][pair[0]])} vs {_team_name(state['players'][pair[1]])}" for pair in round_item.get("pairs", []) ] bye = str(round_item.get("bye") or "") if bye: rendered.append(f"{_team_name(state['players'][bye])} vs 本地陪练队") return ";".join(rendered) def _training_match_part( state: dict[str, Any], result: dict[str, Any], round_number: int, ) -> str: item = result["maps"][0] winner_key = str(result["winner_key"]) loser_key = str(result["loser_key"]) winner = state["players"][winner_key] loser = state["players"][loser_key] return "\n".join( [ ( f"训练赛第 {round_number} 轮:{_team_name(winner)} " f"vs {_team_name(loser)}" ), ( f"{item['map_name']}:{item['opening_text']}" f"{item['run_text']}" ), ( f"{item['clutch_text']}{item['closer_text']}" f"{_team_name(winner)} 以 {item['score']} 拿下本图。" ), ( f"摩拉:{_team_name(winner)} +{TRAINING_WIN_GOLD}," f"{_team_name(loser)} +{TRAINING_LOSS_GOLD}。" ), ] ) def _training_bye_part(state: dict[str, Any], key: str, round_number: int) -> str: player = state["players"][key] player["gold"] = int(player["gold"]) + TRAINING_BYE_GOLD hook = _pick_fragment( "training_hooks", f"{state['run_id']}|training|bye|{round_number}|{key}", ) return ( f"训练赛第 {round_number} 轮:{_team_name(player)} 与本地陪练队完成热身。\n" f"{hook}\n摩拉:{_team_name(player)} +{TRAINING_BYE_GOLD}。" ) def _settle_training_round( state: dict[str, Any], event_id: str, ) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]: _ensure_training_schedule(state) rounds = list(state.get("training_rounds", [])) cursor = int(state.get("training_round_cursor", 0)) if cursor >= len(rounds): state["phase"] = "training_complete_manage" _prepare_new_shops(state, "training-complete") _reset_ready(state, _human_keys(state)) return [ "训练赛已经全部结束。可以最后 D 牌、调阵容;所有真人发送 /结束回合 后进入小组赛。" ], [], [] round_item = rounds[cursor] round_number = int(round_item["round"]) state["round_number"] = round_number parts: list[str] = [] stories: list[dict[str, Any]] = [] battles: list[dict[str, Any]] = [] if cursor == 0: intro = ( f"欢迎来到{state['event_title']}!你们已经到达酒店宾馆," f"训练赛共 {len(rounds)} 轮。训练赛不扣生命,只产出运营摩拉。" ) parts.append(intro) stories.append(_story(state, "training_open", intro)) hook = _pick_fragment( "training_hooks", f"{state['run_id']}|training|hook|{round_number}", ) parts.append( f"训练赛第 {round_number}/{len(rounds)} 轮对阵:" f"{_training_pairing_line(state, round_item)}\n{hook}" ) for match_index, pair in enumerate(round_item.get("pairs", []), start=1): key_a, key_b = str(pair[0]), str(pair[1]) result = _battle( state, key_a, key_b, f"{state['run_id']}|training|{round_number}|{match_index}|{event_id}", best_of=MATCH_BEST_OF["training"], ) winner_key = str(result["winner_key"]) loser_key = str(result["loser_key"]) state["players"][winner_key]["gold"] = ( int(state["players"][winner_key]["gold"]) + TRAINING_WIN_GOLD ) state["players"][loser_key]["gold"] = ( int(state["players"][loser_key]["gold"]) + TRAINING_LOSS_GOLD ) result["training_rewards"] = { winner_key: TRAINING_WIN_GOLD, loser_key: TRAINING_LOSS_GOLD, } battle = _battle_record( state, battle_type="training_bo1", player_a=key_a, player_b=key_b, result=result, ) text = _training_match_part(state, result, round_number) parts.append(text) stories.append(_story(state, "training_result", text, result)) battles.append(battle) bye = str(round_item.get("bye") or "") if bye: text = _training_bye_part(state, bye, round_number) parts.append(text) stories.append(_story(state, "training_bye", text, {"person_key": bye})) state["training_round_cursor"] = cursor + 1 _prepare_new_shops(state, f"training-{round_number}") _reset_ready(state, _human_keys(state)) if cursor + 1 < len(rounds): state["phase"] = "training_manage" parts.append( "本轮训练结束。现在是运营窗口:/刷新 是 D 牌,/选1~/选6 是买店内选手," "/锁定 可保留商店,/上阵 可调整首发;所有真人发送 /结束回合 后进入下一轮训练。" ) else: state["phase"] = "training_complete_manage" parts.append( "训练赛全部结束。可以最后 D 牌、调阵容;所有真人发送 /结束回合 后进入小组赛。" ) return parts, stories, battles def _prepare_new_shops(state: dict[str, Any], reason: str) -> None: for key in state["join_order"]: player = state["players"][key] if not player.get("shop_locked"): player["shop"] = _draw_shop( state, f"{state['run_id']}|{reason}|{key}|{state.get('match_sequence', 0)}", ) def _group_schedule(state: dict[str, Any]) -> list[dict[str, Any]]: matches: list[dict[str, Any]] = [] for round_index, pairs in enumerate(_round_robin_rounds(list(state["seed_order"])), start=1): paired = {key for pair in pairs for key in pair} bye = next((key for key in state["seed_order"] if key not in paired), "") for match_index, pair in enumerate(pairs, start=1): matches.append( { "stage": "group", "round": round_index, "match": match_index, "pair": [str(pair[0]), str(pair[1])], "best_of": MATCH_BEST_OF["group"], "bye": bye, } ) return matches def _match_title(state: dict[str, Any], match: dict[str, Any]) -> str: key_a, key_b = match["pair"] player_a = state["players"][key_a] player_b = state["players"][key_b] if match["stage"] == "group": return ( f"小组赛第 {match['round']} 轮第 {match['match']} 场:" f"{_team_name(player_a)} vs {_team_name(player_b)}(BO{match['best_of']})" ) label = "淘汰赛半决赛" if match["stage"] == "semifinal" else "总决赛" return f"{label}:{_team_name(player_a)} vs {_team_name(player_b)}(BO{match['best_of']})" def _match_ready_keys(state: dict[str, Any], match: dict[str, Any]) -> list[str]: if match.get("stage") == "group": return _human_keys(state) return [ key for key in match.get("pair", []) if not state["players"][key].get("is_ai") ] def _set_current_match(state: dict[str, Any], match: dict[str, Any]) -> list[str]: state["current_match"] = match state["phase"] = f"{match['stage']}_ready" keys = _match_ready_keys(state, match) _reset_ready(state, keys) parts = [_match_title(state, match)] if keys: names = "、".join(state["players"][key]["display_name"] for key in keys) parts.append(f"{names},请确认阵容;准备完成发送 /结束回合。") else: parts.append("本场为 AI 内战,将自动结算。") return parts def _start_group_parts(state: dict[str, Any]) -> list[str]: state["group_matches"] = _group_schedule(state) state["group_match_cursor"] = 0 state["match_sequence"] = 0 pair_lines = [] rounds = _round_robin_rounds(list(state["seed_order"])) for round_index, pairs in enumerate(rounds, start=1): rendered = [] paired = {key for pair in pairs for key in pair} bye = next((key for key in state["seed_order"] if key not in paired), "") for match_index, pair in enumerate(pairs, start=1): rendered.append( f"第 {round_index} 轮第 {match_index} 场 " f"{_team_name(state['players'][pair[0]])} vs {_team_name(state['players'][pair[1]])}" ) if bye: rendered.append(f"{state['players'][bye]['display_name']} 轮空") pair_lines.append(";".join(rendered)) parts = [ ( f"欢迎来到{state['event_title']}正赛小组赛!" f"小组赛采用单循环 BO{MATCH_BEST_OF['group']}。" ), "本阶段对阵:\n" + "\n".join(pair_lines), ] if state["group_matches"]: parts.extend(_set_current_match(state, state["group_matches"][0])) return parts def _key_player_name(state: dict[str, Any], key: str, offset: int) -> str: board = state["players"][key].get("board", []) if not board: return state["players"][key]["display_name"] unit = board[offset % len(board)] return str(CARD_BY_ID[str(unit["card_id"])]["name"]) def _series_report_parts( state: dict[str, Any], match: dict[str, Any], result: dict[str, Any], ) -> list[str]: key_a, key_b = match["pair"] matchup = result["matchup"] parts = [ ( f"{_match_title(state, match)} 开始。" f"战术面是 {matchup['tactic_a']} 对 {matchup['tactic_b']}," "本场按逐回合模拟推进。" ) ] wins: dict[str, int] = {key_a: 0, key_b: 0} for index, item in enumerate(result["maps"], start=1): winner_key = str(item["winner_key"]) loser_key = str(item["loser_key"]) wins[winner_key] += 1 lead_text = ( f"{_team_name(state['players'][winner_key])} 在系列赛中 " f"{wins[winner_key]}-{wins[loser_key]} {'领先' if wins[winner_key] > wins[loser_key] else '追平'}" ) parts.append( f"图{index} {item['map_name']}:{item['opening_text']}" f"{item['run_text']}{item['clutch_text']}" f"{item['economy_text']}{item['closer_text']}" f"{_team_name(state['players'][winner_key])} 以 {item['score']} 拿下地图;{lead_text}。" ) winner_key = str(result["winner_key"]) loser_key = str(result["loser_key"]) reward = ( GROUP_SERIES_WIN_GOLD if match["stage"] == "group" else PLAYOFF_SERIES_WIN_GOLD ) state["players"][winner_key]["gold"] = int(state["players"][winner_key]["gold"]) + reward final_text = ( f"系列赛结束,{_team_name(state['players'][winner_key])} 以 {result['score']} 击败 " f"{_team_name(state['players'][loser_key])},获得 {reward} 摩拉。" "这笔钱可以马上用于 /刷新、/选1~/选6 或锁店等下一轮运营。" ) parts[-1] = parts[-1] + "\n" + final_text if len(parts) <= 5: return parts return [parts[0], parts[1], parts[2], "\n".join(parts[3:-1]), parts[-1]] def _settle_current_match( state: dict[str, Any], event_id: str, ) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]: match = dict(state["current_match"]) key_a, key_b = match["pair"] state["match_sequence"] = int(state.get("match_sequence", 0)) + 1 result = _battle( state, key_a, key_b, f"{state['run_id']}|{match['stage']}|{state['match_sequence']}|{event_id}", best_of=int(match["best_of"]), ) _apply_match_result( state, key_a, key_b, result, group_stage=match["stage"] == "group", ) battle_type = "group_bo3" if match["stage"] == "group" else f"{match['stage']}_bo5" battle = _battle_record( state, battle_type=battle_type, player_a=key_a, player_b=key_b, result=result, ) parts = _series_report_parts(state, match, result) story = _story(state, f"{match['stage']}_result", "\n".join(parts), result) return parts, [story], [battle] def _standings_part(state: dict[str, Any]) -> str: lines = ["小组赛排名:"] for index, key in enumerate(_rankings(state), start=1): player = state["players"][key] total = max(1, int(player["wins"]) + int(player["losses"])) rate = int(player["wins"]) / total lines.append( f"{index}. {_team_name(player)} {player['wins']}胜{player['losses']}负 胜率{rate:.0%}" ) return "\n".join(lines) def _special_event_parts( state: dict[str, Any], event_id: str, ) -> tuple[list[str], list[dict[str, Any]]]: event_key = "media" if _hash_int(state["run_id"] + event_id) % 2 == 0 else "sponsor" event_kind, target, win_gold, loss_gold = SPECIAL_EVENTS[event_key] parts = [f"{event_kind}突然插入。本届只会出现一次这种挑战;完成后继续小组赛。"] stories: list[dict[str, Any]] = [] for key in state["join_order"]: player = state["players"][key] power = lineup_power(player)["power"] won = power >= target reward = win_gold if won else loss_gold player["gold"] = int(player["gold"]) + reward hook = _pick_fragment( "challenges", f"{state['run_id']}|{event_kind}|{event_id}|{key}", ) text = ( f"{_team_name(player)} {'完成' if won else '勉强应付'}{event_kind}:" f"{hook}获得 {reward} 摩拉。" ) parts.append(text) stories.append(_story(state, "special_event", text, {"person_key": key})) state["special_event_done"] = True state["phase"] = "special_manage" _prepare_new_shops(state, event_kind) _reset_ready(state, _human_keys(state)) parts.append("挑战结束。可以继续 D 牌和调整阵容;所有真人发送 /结束回合 后继续小组赛。") return parts, stories def _playoff_matches_from_rankings(state: dict[str, Any]) -> list[dict[str, Any]]: ranking = _rankings(state) if len(ranking) >= 4: qualifiers = ranking[:4] state["qualifiers"] = qualifiers return [ { "stage": "semifinal", "round": 1, "match": 1, "pair": [qualifiers[0], qualifiers[3]], "best_of": MATCH_BEST_OF["semifinal"], }, { "stage": "semifinal", "round": 1, "match": 2, "pair": [qualifiers[1], qualifiers[2]], "best_of": MATCH_BEST_OF["semifinal"], }, ] finalists = ranking[:2] state["finalists"] = finalists return [ { "stage": "final", "round": 1, "match": 1, "pair": finalists, "best_of": MATCH_BEST_OF["final"], } ] def _playoff_pairings_part(state: dict[str, Any]) -> str: lines = ["淘汰赛对阵:"] for match in state.get("playoff_matches", []): key_a, key_b = match["pair"] seed_a = state["players"][key_a].get("seed", "?") seed_b = state["players"][key_b].get("seed", "?") label = "半决赛" if match["stage"] == "semifinal" else "总决赛" lines.append( f"{label}:{_team_name(state['players'][key_a])}({seed_a}) vs {_team_name(state['players'][key_b])}({seed_b})" ) return "\n".join(lines) def _finish_tournament_parts(state: dict[str, Any]) -> list[str]: champion_key = str(state["champion_key"]) champion = state["players"][champion_key] power = lineup_power(champion) state["status"] = "completed" state["phase"] = "completed" return [ f"{state['event_title']} 全部比赛结束!", ( f"冠军:{_team_name(champion)}。最终阵容战力 {power['power']:.1f}," f"小组赛 {champion['wins']}胜{champion['losses']}负。" ), "颁奖:MVP 给到本届决赛关键先生;所有赛报已经写入自走棋档案,可用 /自走棋档案 查看。", ] class CsMajorAutochess: """Command facade; persistence remains behind the Kernel Ledger service.""" def __init__(self, ledger: Any) -> None: self.ledger = ledger @staticmethod def rules() -> str: return ( "CS自走棋:/自走棋开始 创建报名;/自走棋报名;主持人 /自走棋开赛。\n" "两名或三名真人报名后,主持人可用 /自走棋加AI战队," "分别开启 2 人+2 AI 或 3 人+1 AI 测试模式。\n" f"开赛后 {DRAFT_ROUNDS} 轮选秀:第一轮固定 {DRAFT_COACH_CHOICE_COUNT} 名教练," "后续轮次选选手。" "选秀完成后每队 5 名首发、1 名教练、3 个备战席。\n" "/选1~/选6;/阵容;/刷新;/锁定;/上阵 备战编号 [首发位];/出售 备战编号。" "/战术 火力|稳定|残局,稳定克火力、火力克残局、残局克稳定。\n" "训练赛按参赛人数生成多轮真实对阵,每轮结束后可 D 牌和调阵容;" f"随后进入 BO{MATCH_BEST_OF['group']} 单循环小组赛," f"半决赛/决赛 BO{MATCH_BEST_OF['final']}。" "所有需要推进的阶段由相关真人发送 /结束回合,满足条件后自动进入下一步。" ) def handle(self, row: dict[str, Any]) -> str: return "\n".join(self.handle_parts(row)) def handle_parts(self, row: dict[str, Any]) -> list[str]: action_kind, arguments = parse_autochess_action(str(row["question"])) group = str(row["group_fingerprint"]) sender = str(row["sender"]) event_id = str(row["event_id"]) command = f"autochess:{action_kind}:{arguments}" def execute( state: dict[str, Any] | None, person: dict[str, str], next_season: int, ) -> dict[str, Any]: result = self._execute( state, person, next_season, action_kind, arguments, event_id, ) parts = result.pop("response_parts", None) if isinstance(parts, list): result["response"] = _pack_response_parts( [str(part) for part in parts] ) return result packed = self.ledger.apply_autochess_action( group, sender, event_id, command, execute, ) return _unpack_response_parts(packed) def _execute( self, state: dict[str, Any] | None, person: dict[str, str], next_season: int, action: str, arguments: str, event_id: str, ) -> dict[str, Any]: person_key = str(person["person_key"]) display_name = str(person["display_name"]) def respond( current: dict[str, Any] | None, parts: list[str] | str, *, stories: list[dict[str, Any]] | None = None, battles: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: normalized = [parts] if isinstance(parts, str) else parts normalized = [str(part) for part in normalized if str(part).strip()] if not normalized: normalized = ["没有可发送内容"] return { "state": current, "response": normalized[0], "response_parts": normalized, "stories": stories or [], "battles": battles or [], } if action == "unknown": return respond(state, self.rules()) if action == "create": legacy_active = ( state is not None and state.get("status") in {"registration", "active"} and int(state.get("schema_version") or 0) < 3 ) if ( state is not None and state.get("status") in {"registration", "active"} and not legacy_active ): return respond( state, f"{state['event_title']} 已在进行中;发送 /自走棋状态 查看", ) season = next_season state = { "schema_version": 3, "run_id": f"major_{season:04d}", "season_number": season, "event_title": EVENT_TITLE_TEMPLATE.format(season=season), "status": "registration", "host_person_key": person_key, "host_display_name": display_name, "round_number": 1, "phase": "registration", "players": {person_key: _participant(person_key, display_name)}, "join_order": [person_key], "pool": { card["id"]: COST_POOL_COPIES[int(card["cost"])] for card in PLAYER_POOL }, "public_shop": [], "seed_order": [], "round_plan": [], "group_results": [], "ai_test_mode": False, "ai_test_variant": "", "ready_keys": [], "qualifiers": [], "finalists": [], "champion_key": "", "stories": [], "battles": [], "story_sequence": 0, "battle_sequence": 0, "special_event_done": False, "training_rounds": [], "training_round_cursor": 0, } text = ( f"{state['event_title']} 开放报名;主持人 {display_name} 已自动加入。" "支持 2~8 人;发送 /自走棋报名,主持人发送 /自走棋开赛。" ) if legacy_active: text = "已保留旧版未完成赛事档案,并开启新版赛季。" + text story = _story(state, "registration_open", text, {"host_person_key": person_key}) return respond(state, text, stories=[story]) if state is None: return respond(None, "当前没有自走棋赛事;发送 /自走棋开始") if action == "join": if state["status"] != "registration": return respond(state, "本届赛事已停止报名") if state.get("ai_test_mode"): return respond(state, "AI 测试模式已经锁定参赛名单,不能再加入真人。") if person_key in state["players"]: return respond(state, f"{display_name} 已经报名。") if len(state["join_order"]) >= MAX_PLAYERS: return respond(state, f"本届赛事已满 {MAX_PLAYERS} 人") state["players"][person_key] = _participant(person_key, display_name) state["join_order"].append(person_key) text = f"{display_name} 加入赛事,当前 {len(state['join_order'])}/{MAX_PLAYERS} 人。" story = _story(state, "player_joined", text, {"person_key": person_key}) return respond(state, text, stories=[story]) if action == "add_ai": if state["status"] != "registration": return respond(state, "AI 战队只能在报名阶段加入") if person_key != state["host_person_key"]: return respond(state, f"只有主持人 {state['host_display_name']} 可以加入 AI 战队") if any(player.get("is_ai") for player in state["players"].values()): return respond(state, "AI 测试战队已经加入") human_keys = [ key for key in state["join_order"] if not state["players"][key].get("is_ai") ] if len(human_keys) not in {2, 3}: return respond(state, "AI 测试模式要求先有且仅有 2 名或 3 名真人报名") if len(human_keys) == 2: ai_teams = ( (AI_PERSON_KEY, "AI 战队 A"), (AI_SECOND_PERSON_KEY, AI_SECOND_DISPLAY_NAME), ) variant = "two_plus_two" text = "AI 战队 A、AI 战队 B 已作为第 3、4 种子加入;2 人+2 AI 测试赛已锁定。" else: ai_teams = ((AI_PERSON_KEY, AI_DISPLAY_NAME),) variant = "three_plus_one" text = "AI 战队已作为第 4 种子加入;3 人+1 AI 测试赛已锁定。" for ai_key, ai_name in ai_teams: state["players"][ai_key] = _participant(ai_key, ai_name, is_ai=True) state["join_order"].append(ai_key) state["ai_test_mode"] = True state["ai_test_variant"] = variant story = _story(state, "ai_team_joined", text, {"variant": variant}) return respond(state, text, stories=[story]) if action == "launch": if state["status"] != "registration": return respond(state, "本届赛事已经开赛或结束") if person_key != state["host_person_key"]: return respond(state, f"只有主持人 {state['host_display_name']} 可以开赛") if len(state["join_order"]) < MIN_PLAYERS: return respond(state, f"至少需要 {MIN_PLAYERS} 人才能开赛") state["status"] = "active" state["seed_order"] = list(state["join_order"]) state["round_plan"] = build_round_plan(list(state["join_order"])) for seed, key in enumerate(state["seed_order"], start=1): state["players"][key]["seed"] = seed _start_draft_round(state, 1) names = "、".join( state["players"][key]["display_name"] for key in state["join_order"] ) mode = ( "2 人+2 AI 测试赛" if state.get("ai_test_variant") == "two_plus_two" else "3 人+1 AI 测试赛" if state.get("ai_test_variant") == "three_plus_one" else f"{len(state['join_order'])} 队赛" ) parts = [ f"欢迎大家来到{state['event_title']}!", f"参赛者:{names}。", ( f"种子顺序:{_seed_lines(state)}。本届采用 {mode}:" f"先进行 {DRAFT_ROUNDS} 轮选秀,随后按轮次训练赛、" f"小组赛 BO{MATCH_BEST_OF['group']} 单循环," f"淘汰赛 BO{MATCH_BEST_OF['final']};单图 MR15," f"常规时间 {BATTLE_OVERTIME_START_SCORE}-{BATTLE_OVERTIME_START_SCORE} 进入加时。" ), _draft_board_part(state), ] parts.extend(_auto_advance_draft(state)) parts.extend(_finish_draft_round_if_needed(state)) if state.get("phase", "").startswith("draft_"): parts.append(_draft_prompt_part(state)) story = _story( state, "tournament_started", "\n".join(parts), {"players": list(state["join_order"])}, ) return respond(state, parts, stories=[story]) if action == "archive": page_values = _parse_indices(arguments) return respond(state, _archive_text(state, page_values[0] if page_values else 1)) if action == "schedule": return respond(state, _schedule_text(state)) if state["status"] == "registration": return respond(state, _schedule_text(state)) if state["status"] in {"completed", "cancelled"}: if action in {"status", "lineup"}: return respond(state, _schedule_text(state)) return respond(state, f"{state['event_title']} 已结束;发送 /自走棋开始 创建下一届") player = state["players"].get(person_key) if player is None: return respond(state, "你没有参加本届赛事,只能查看 /自走棋赛程") if action == "status": return respond(state, _shop_status_text(state, player)) if action == "lineup": return respond(state, _full_lineup_text(state, player)) if state.get("phase", "").startswith("draft_"): if action != "buy": return respond(state, _draft_prompt_part(state)) draft = state["draft"] order = list(draft["order"]) cursor = int(draft.get("cursor", 0)) if cursor >= len(order): return respond(state, "本轮选秀已经完成,请稍等主持进入下一轮。") current_key = str(order[cursor]) if current_key != person_key: return respond( state, f"当前选择的是 @{state['players'][current_key]['display_name']},请等待。", ) values = _parse_indices(arguments) if len(values) != 1: return respond( state, ( f"用法:/选1~/选{DRAFT_COACH_CHOICE_COUNT}(教练)" f"或 /选1~/选{DRAFT_PLAYER_CHOICE_COUNT}(选手)" ), ) try: text = _apply_draft_choice(state, person_key, values[0]) except ValueError as error: return respond(state, str(error)) parts = [text] parts.extend(_auto_advance_draft(state)) parts.extend(_finish_draft_round_if_needed(state)) if state.get("phase", "").startswith("draft_"): parts.append(_draft_prompt_part(state)) story = _story(state, "draft_pick", text, {"person_key": person_key}) return respond(state, parts, stories=[story]) if action == "tactic": value = re.sub(r"\s+", "", arguments.casefold()) aliases = { "火力": "火力", "fire": "火力", "稳定": "稳定", "stable": "稳定", "残局": "残局", "clutch": "残局", } if not value: return respond(state, f"当前战术 {player['tactic']};用法:/战术 火力|稳定|残局") tactic = aliases.get(value) if tactic is None: return respond(state, "战术只能选择:火力、稳定、残局") player["tactic"] = tactic text = f"{display_name} 将战术设为 {tactic}。" story = _story(state, "tactic_selected", text, {"person_key": person_key, "tactic": tactic}) return respond(state, text, stories=[story]) if action == "coach": return respond(state, "教练现在由第一轮选秀决定,本届不能用摩拉升级教练。") if action == "buy": values = _parse_indices(arguments) if len(values) != 1 or not 1 <= values[0] <= SHOP_SIZE: return respond(state, "用法:/选1 到 /选6") slot = values[0] - 1 card_id = player["shop"][slot] if slot < len(player.get("shop", [])) else None if card_id is None: return respond(state, "这个商店位置已经购买或没有候选") card = CARD_BY_ID[str(card_id)] if int(player["gold"]) < int(card["cost"]): return respond(state, f"摩拉不足:{card['name']} 需要 {card['cost']},当前 {player['gold']}") existing = any( unit["card_id"] == card_id for unit in [*player["board"], *player["bench"]] ) if not existing and len(player["bench"]) >= BENCH_LIMIT: return respond(state, f"备战席已满 {BENCH_LIMIT} 格,请先出售") if int(state["pool"].get(card_id, 0)) <= 0: return respond(state, "共享卡池中的这名选手已被拿完,请刷新") player["gold"] -= int(card["cost"]) state["pool"][card_id] -= 1 star, upgraded = _merge_purchase(player, str(card_id)) player["shop"][slot] = None text = ( f"{display_name} 签下 {card['name']},花费 {card['cost']} 摩拉;" f"当前 {star} 星,余额 {player['gold']}。" ) if upgraded: text += f"{card['name']} 完成升星。" story = _story(state, "shop_purchase", text, {"person_key": person_key, "card_id": card_id}) return respond(state, text, stories=[story]) if action == "refresh": if int(player["gold"]) < REFRESH_COST: return respond(state, f"刷新需要 {REFRESH_COST} 摩拉,当前 {player['gold']}") player["gold"] -= REFRESH_COST player["shop"] = _draw_shop( state, f"{state['run_id']}|{state.get('phase')}|{event_id}|{person_key}|refresh", ) return respond(state, _shop_status_text(state, player)) if action == "lock": player["shop_locked"] = not bool(player["shop_locked"]) return respond(state, f"{display_name} {'锁定' if player['shop_locked'] else '解锁'}了商店。") if action in {"field", "bench"}: values = _parse_indices(arguments) if not values: return respond(state, "用法:/上阵 备战编号 [首发位],例如 /上阵 6 2") found = _find_unit(player, values[0]) if found is None or found[0] != "备战": return respond(state, "第一个编号必须是 /阵容 中的备战席编号") bench_index = found[1] board_index = ( values[1] - 1 if len(values) >= 2 and 1 <= values[1] <= STARTER_COUNT else min(range(STARTER_COUNT), key=lambda index: _unit_power(player["board"][index])) ) incoming = player["bench"][bench_index] outgoing = player["board"][board_index] player["board"][board_index] = incoming player["bench"][bench_index] = outgoing text = ( f"{display_name} 用 {CARD_BY_ID[str(incoming['card_id'])]['name']} " f"替换首发 {board_index + 1} 的 {CARD_BY_ID[str(outgoing['card_id'])]['name']}。" ) story = _story(state, "lineup_change", text, {"person_key": person_key}) return respond(state, text, stories=[story]) if action == "sell": values = _parse_indices(arguments) if len(values) != 1: return respond(state, "用法:/出售 备战编号") found = _find_unit(player, values[0]) if found is None: return respond(state, "没有这个阵容编号") if found[0] == "首发": return respond(state, "首发必须保持 5 人;先用 /上阵 备战编号 首发位 完成替换") unit = player["bench"].pop(found[1]) card = CARD_BY_ID[str(unit["card_id"])] refund = int(card["cost"]) * int(unit["copies"]) player["gold"] += refund state["pool"][card["id"]] += int(unit["copies"]) return respond(state, f"{display_name} 出售 {card['name']},返还 {refund} 摩拉。") if action == "ready": if person_key not in state.get("ready_keys", []): if state.get("phase") in {"semifinal_ready", "final_ready"}: return respond(state, f"{display_name} 已不在当前淘汰赛对阵中,不需要准备。") return respond(state, "当前阶段不需要你确认 /结束回合。") player["ready"] = True waiting = _ready_waiting(state) if waiting: names = "、".join(state["players"][key]["display_name"] for key in waiting) return respond(state, f"{display_name} 准备完成;仍在等待:{names}。") phase = str(state.get("phase")) stories: list[dict[str, Any]] = [] battles: list[dict[str, Any]] = [] if phase in {"lineup_prep", "training_manage"}: parts, stories, battles = _settle_training_round(state, event_id) return respond(state, parts, stories=stories, battles=battles) if phase == "training_complete_manage": parts = _start_group_parts(state) return respond(state, parts) if phase == "group_ready": parts, stories, battles = _settle_current_match(state, event_id) state["group_match_cursor"] = int(state.get("group_match_cursor", 0)) + 1 state["last_completed_group_round"] = int(state["current_match"]["round"]) state["phase"] = "group_manage" _prepare_new_shops(state, "group") _reset_ready(state, _human_keys(state)) parts.append("本场结束。可以 D 牌、调阵容;所有真人发送 /结束回合 后进入下一场。") return respond(state, parts, stories=stories, battles=battles) if phase == "group_manage": cursor = int(state.get("group_match_cursor", 0)) matches = list(state.get("group_matches", [])) last_round = int(state.get("last_completed_group_round", 0)) next_round = int(matches[cursor]["round"]) if cursor < len(matches) else 999 if ( not state.get("special_event_done") and last_round in {1, 2} and next_round > last_round ): parts, stories = _special_event_parts(state, event_id) return respond(state, parts, stories=stories) if cursor < len(matches): parts = _set_current_match(state, matches[cursor]) return respond(state, parts) state["playoff_matches"] = _playoff_matches_from_rankings(state) state["playoff_cursor"] = 0 parts = [_standings_part(state), _playoff_pairings_part(state)] if state["playoff_matches"]: parts.extend(_set_current_match(state, state["playoff_matches"][0])) return respond(state, parts) if phase == "special_manage": cursor = int(state.get("group_match_cursor", 0)) matches = list(state.get("group_matches", [])) if cursor < len(matches): return respond(state, _set_current_match(state, matches[cursor])) return respond(state, "挑战结束,但没有后续小组赛对阵。") if phase in {"semifinal_ready", "final_ready"}: parts, stories, battles = _settle_current_match(state, event_id) match = dict(state["current_match"]) winner_key = str( battles[0]["result"]["winner_key"] if battles else state["current_match"]["pair"][0] ) if match["stage"] == "final": state["champion_key"] = winner_key parts.extend(_finish_tournament_parts(state)) return respond(state, parts, stories=stories, battles=battles) winners = list(state.get("semifinal_winners", [])) winners.append(winner_key) state["semifinal_winners"] = winners state["playoff_cursor"] = int(state.get("playoff_cursor", 0)) + 1 cursor = int(state["playoff_cursor"]) playoff_matches = list(state.get("playoff_matches", [])) if cursor < len(playoff_matches): parts.extend(_set_current_match(state, playoff_matches[cursor])) return respond(state, parts, stories=stories, battles=battles) final_match = { "stage": "final", "round": 1, "match": 1, "pair": winners[:2], "best_of": MATCH_BEST_OF["final"], } state["finalists"] = winners[:2] parts.append( f"半决赛全部结束,胜者进入 BO{MATCH_BEST_OF['final']} 总决赛。" ) parts.extend(_set_current_match(state, final_match)) if not state.get("ready_keys"): final_parts, final_stories, final_battles = _settle_current_match( state, event_id + "-auto-final", ) final_winner = str(final_battles[0]["result"]["winner_key"]) state["champion_key"] = final_winner final_parts.extend(_finish_tournament_parts(state)) parts.extend(final_parts) stories.extend(final_stories) battles.extend(final_battles) return respond(state, parts, stories=stories, battles=battles) return respond(state, "当前阶段已经准备完成,请等待下一步。") if action in {"settle", "force_settle"}: return respond(state, "0.6.6 起赛事由 /结束回合 自动推进,不再需要主持人手动结算。") if action == "cancel": if person_key != state["host_person_key"]: return respond(state, f"只有主持人 {state['host_display_name']} 可以取消赛事") state["status"] = "cancelled" state["phase"] = "cancelled" text = f"{state['event_title']} 已由主持人取消;已完成剧情仍保留在档案。" story = _story(state, "tournament_cancelled", text) return respond(state, text, stories=[story]) return respond(state, self.rules())