diff --git a/.ace-tool/index.json b/.ace-tool/index.json new file mode 100644 index 0000000..3ec752c --- /dev/null +++ b/.ace-tool/index.json @@ -0,0 +1,111 @@ +[ + "ed502963d93cad1b4573f4d186eafb23c2b053f914c112c54e53a47f2281daa6", + "ec44b5f6f29ac10270f275778e131c65869fb0864010e89b69698905704832f3", + "78f811e27bb1e3a2ad4882b40970c46aea2a0426bd52cc2869fd66f09959f692", + "801068c49b7b19c94a342c0c0589e3210236fe50732960a1a97f46b38af900e4", + "e0aa3d0b44303429657c8c395562056eea3b2910e1ca3ca21e5a2d7630520188", + "4c26efc3cda204cc48cf4d199fb0fa98e9625db5e7251274692e1b4162a0057a", + "abf7bd0f1f8ce57a8e7208d5c68bec29d9d6d8cc2f17912f154f568197668283", + "a1c4ef143f0b5131a1e475d3288c7a7e5bfc423be383d32b574f5e6e302f0b99", + "ad1093bc368202eff7151758bcba2f97a540b22a97d4543534003b1ff22c351e", + "26f72c5dd5a15b68fd551f7e418b6bc57c0b12eee8ecf821d0f519a50cc4ada0", + "a5bac20ec492c3dda5cce5bb9b37a45314dc827f54c9752a655f19cee377cbad", + "5ef820c37357d4aa985512034ab267ea39a5f97ccebbeb6a982b5775f5c90f9e", + "5787f8fd04702821044ddbd39134319515a7e5b17bd1e2f6686a73e72108a977", + "93ab010913bf3adba344274b3b12d775e38576d3d105386b493d3b1d5d8a651a", + "757e1bababc109505707fe48c148f19f25c0548541cb1a9b0902848d858d15bb", + "f545342ccfc3c165bf1fa0b4c5fa611fad66943d3a5838a82f0f72dd9f804f16", + "2d4f0fc8acbf6d453b242c1ae7bb41ee3dc2b35c0986be061a6c74ac163ca13f", + "90b48700fd17b050bd8b37f22bfedd3977f11022947a9f0fa2f0c2111681f220", + "6274e81aa91cd6f4aea2a9979cc9a7128b069b955c99301231247ea529721abf", + "b6b47b9a6b066fce6ad066d75a9ccee35229a5af35e14063dc46d99707a33d7d", + "f39f0d7db0d4aa4323db60033f7d15702f244f6fd5d8d627e9199b6101f5cb14", + "5c1e01f8ef5099cefeb216f30397b877b83deb1b1bb60ac602da8734d7ac4cab", + "a1e872599352cbe9ed4666a6a724c5918354d1893a4a8ef92495ef26f1a20763", + "c103c25156eb574823b979768acb6e8b63eafaff6ea85de7ed3dab3d3608b19c", + "40946f0878eedb85d9b7d12f2b1ff6a1b4ed720291251d063b3dbe573f9d27c4", + "379fe99f66104fff63d31a1b1ab8c5d302fb9b7bc7222ecd3ec13c16b7945a87", + "a9a9de4f2d20146fb96e6d526a88afd6d116a8154e2271946f49c3ddb9408b47", + "818e9ed7cdd34fd934c35b4604b8b6c93150c865e6615b98e158ea41dc501d54", + "4ae84ba497bb11d4356c4df2ed67f40bb1a1abf9f27e414c3730ea5d4dbf85b2", + "a94de0141b5fbd88034ef443210392792e337f2d9bc53b32cfaaca36dda9e17e", + "d620cbb163426505440c7a0443e9c6ad2697f0b692d612d3af791390e9990eaa", + "3307d732604695ea96392b4c1326d98aee7a571558ff7ce790e83683cda4a467", + "bcb8b9bfcf48ee7dc25b6124f3675d464881919229b250e053cfc04579623feb", + "41cc2d5fae52202782110f57c2a993c5c092d3eaf433a6e7004ed52cead39e99", + "695ff3017e7656b401c8d90b93ad7595f3633d959153602a0d41fe664e4454dc", + "fe2cd5167e8a9e60457448af8bc5359e6606d5fac059d3d815b82e1232b2a968", + "c9bcf4422eee790282c21cc41fbaa2b9af56515740bbd732879b3db340704bf3", + "d24285671c0b3f9f8e2ba681a80aa16f673b83985933abb0a523d20856290427", + "cba2c5f40fcb62ed6056408b3a77919a16e69512831da0080a3849e8b8697986", + "90577d61220eb2b0678d415d44c154da9f4ee40f7d587f3837e72afa8756511a", + "484a75be2e9619ceecd055c905186a6a3e851b066218dc7b4417090116167341", + "66a8ee003caa020900d66f75506a0d5b10e86fd8965d58cf94da80078c86e1cd", + "874127247a8277a4523697b4c3e35039f720d557956227abf30d998f62f2e46c", + "29d05e8433569e3cb1883cee9d07df0224374076104e134f4782cb68f62b18f1", + "dae8e648021259f7a66b98e8e33a979671d00056eba34f0720b8e620480b1cd8", + "a70f2a9c228354122c1595b9e2b083719f93c2af079683d1410462c3ff78a55e", + "b74ee9207ac599d32c0206840f4608de126b327f4aa66635e03182047f74c35a", + "8e3a6cc1d231173d9d2c2f144fa675e2bf8c1fdf405b8e26f6740c98e51d9e28", + "79387785a701d2ce6942a52db8967fc0a81d854772fe26a2f7f12df425be1f2a", + "26f6b3d1d8f0d7a80161f4c9714dc3ca863e724d5c1024000a27ea5e7a2a6553", + "5386430aafb755036c8705f51b53e3b9f2ff69a1245117bccc6115f9d3a370a1", + "d6d118e6cb936d00c915c837b44a9f241ccdbc4aa1cedb207462f876290c3f3c", + "37b7953fd37b3e287df75bb6931078af7fd53d7ca6f8b47d25edc876a9baee82", + "95a856bf164bb34acda9028095dae3afe27206d4a0bbf643acff8ad26f354a1d", + "42f5cbf67162ffed7e3dce4989ad25df6a1f965c5a9a792e12e60fae3f57b6ac", + "d00c585b417d4b8e86eeb2da87852d9f3682ee8ecb5ec3b986bc6fc10c8c4afe", + "fe9e3361c8a38d36fe7d05af1b16d2c26d779d74e5847b2831a72009fc440070", + "02402507634ef9fe3b6bb059ffe25c524bc44e1e6aae0429210f5ba2cd5f47f6", + "9796cadf780443d7fce8e22997df6f498e737c3d6c7b9d3232e5a29a537ce131", + "4219fcd91c476955538a80ce393c13d291d635ad05f6158c2ca5cf004385bbba", + "4e85967bcbaa9458b3c8b325ef12ffb4f2f4a7e770cc0aca98b975975b2305b2", + "666d3c3416bb2307b1c83c19b72fac414c1813de3829499e8c0fc59566bf3102", + "59959a36730470d1e4b8f917bf586796a65d598094548fa7f4622f053ff497a6", + "96e353630eae2488c92157b716d1bbdf52752e531396e67640e96dd441f8e124", + "d9ddaecbf4574fd9ec3f61771d8f1c5b4821c873fb87a8e5714878b454268441", + "a5c6f3b89c71f9f669b1024291b771ba09b88c791d9863cebe49ba721aebea72", + "bea512cd140484509d04d4a723290ba4b8a7fca64a2b8effe7b066542b0fcc39", + "1d5f064747afa7008a9b0c711e0c3639f51bc2eb359ddc84e5c0a660ec13a838", + "ce5fa4443a66aeb93a5d51661f0fe9889cfd37cafc453408747ab2bfa5e1c0f6", + "02f05da567f5250eabaac823d4b827d46e6ddf0ce2a2ef5c347e6b4ad8db4a8e", + "5d15e0dd312241447ccde28758276f800cae00d102dca9f68fd319b35d276e3f", + "c026005d0279e745b959ff60517d89646d1d7baacc7f98a1cac0bda099147efd", + "5b1db2930bdccbd2f1254d7e22481ae59d810ead617a280979a288f2f1667a06", + "1c1adc29c26c8323feddb766c74b9221989d4b13d8e17a8fe2927636ac56686d", + "fb8be6618a9ddd2387b41c7574da90f288155b35232b64cc3ad16fd262584cf2", + "6ee266dbc54478780c32a120f889cc32291650217b6482b359be6a2165a3c1ae", + "7af6aafee82e825997acb72f66780de78d658f699f26cebd2e19acde01b8fb87", + "668a6f277ca69d08d173debea1ac08e129105484e49aa68cf4d4f4c6e26bdeb1", + "6b2b83cb68f65ca63bd418d0ee94f2bb961da9bb24fe5aa02cc0942d76040fda", + "0a6670cf4614fa27d9be7e7f713bc333853dd6b7b24f604f1706497bb2a03b87", + "3a0fecc674ebdede88d85f80008235c2e03d0046dab68aa6832234af45814c1d", + "fd696ae60c11589a08cff36825cd9f4d91d831cede8c62dc6dfd58e271016e79", + "3bfdff076bfaa2e84866eabae359c18dd57463c83c122f6e692a73f2ef19fa72", + "d710d55c1d7e2d4e7961bf3b189d59c96865425ba6bef2a651c66b7c38a95500", + "0eb8a9b97098ec424504999bfef6a26b6d6f68039b4af831f8b1082ed5e716e3", + "acbd71c3f19a4a4aa1ec3e55685593508d832f181040745d5356ef34b3302c8e", + "b9f37815e87a36df1ecb585ba09ab3910fb75b89741b1d667a826ef8fdcb1a10", + "f64b138395c5dfd21779e635d095a4e767732748e1b5edfc8be6279f69143a26", + "b5ebafef1d744a43307149158b57018b947d45c0d3e1eb42ae012b00d21dfd1a", + "c22894b2a5b544e9831bd4a788fb289059979148bb57c1d885e54a5669f47ce9", + "c37efc039e8a8b1239148669d69833a4921feb690edd88470f6924486a18dd58", + "9339c12c482b3531947885014e40bd88c3423e6021e582efbd75c666ee8fb86e", + "a8d86de6a5aae1ad06fbee3c51d24457f9a67f387e564055ad893f772b40e1b2", + "cfdf598aeeebf38ea8a2929f917d855fab9aebd87f846e6b73327fe43ad248e2", + "89b947e925cd0c51505bec7de62dfe36b67781a61a0345e2acc46909ab910567", + "77f7207a6c99dfb1c296fadb613b200a0a6ce6802b546baab270234a42e63050", + "0587b591ba70dadac453e51989b193e08b7d7c9da4d6f93415c4413c05af15b7", + "145e750c41f608a8f36004c922d4d86039355ebd988d9850aac630faaa8c693b", + "6d6f2fffb49432daebcdc37bcc375269c4b2052424259a329a7fdcce1f631ff1", + "47f2fe35bda1650d038da34e7e4d3eaef06f9aee863397f6b42076d103a17e56", + "2e8a6ae29d0945d65767de2d0cf9e659e0b1c8a40947ae0fc7d2bbedca741e30", + "46bfa506b85419de3dac5e95aeca0c146608e276513873475ba849f7cf14901a", + "2c2277b25f1a08f85e54b568bd96c71868f5c788ec2a490e533b30ce2fbcedc4", + "d72b51edb8fe6d3305810892b72477031f94dfd9deaeac1666dd020ca0eb9aca", + "5cced5d8dae81af217da4faf84f226c8828ebfdf5c7632299697404f8d96e989", + "26f15bc2e0c41ffc5c9e9c6206f8b6a1b408e1a4c9b6d2c5bfc7ec6655f521da", + "323bf70203d3ce2122df4699a48a57995a14becdddff632c2796a435cb552354", + "9b6d1e2f23de48e083673aff7711abe0730f3002178393828ddb21b3a2d8d4ea", + "0497bc6d412ed31eb1f49cece6079963c2f55a999d50d410530da17df54f9013" +] \ No newline at end of file diff --git a/app.py b/app.py index 645e1ba..0821bae 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import re import secrets +from datetime import datetime, timedelta, timezone from urllib.parse import urlencode, urlparse from typing import Any @@ -82,6 +83,7 @@ ERROR_TEXTS = { "invite_status_invalid": "邀请状态无效。", "oauth_identity_invalid": "OAuth 绑定参数无效。", "badge_locked": "该职业徽章尚未解锁。", + "season_daily_card_not_completed": "需要先通关今日赛季日卡后才能解锁资料。", "badge_config_invalid": "职业徽章配置无效,请检查后重试。", "module_not_found": "扩展模组不存在。", "module_patch_invalid": "扩展模组补丁格式无效。", @@ -104,6 +106,18 @@ ERROR_TEXTS = { "shop_order_not_refundable": "该订单当前不可退款。", "tip_self_forbidden": "不能打赏自己。", "card_purchase_required": "该游戏卡为付费内容,请先购买后再创建房间。", + "season_not_found": "当前没有可参与的赛季。", + "season_inactive": "当前赛季未开启或已结束。", + "season_task_not_found": "赛季任务不存在。", + "season_task_not_completed": "赛季任务尚未完成。", + "season_task_claimed": "该赛季任务已领取。", + "season_card_not_found": "赛季活动卡片不存在。", + "season_card_not_completed": "该活动卡片尚未完成。", + "season_card_claimed": "该活动卡片已领取。", + "season_reward_not_found": "赛季奖励不存在。", + "season_reward_claimed": "该赛季奖励已领取。", + "season_reward_locked": "赛季等级不足,暂不能领取该奖励。", + "season_pass_required": "该奖励需要解锁高级通行证。", } ALLOWED_THEMES = {"minimal", "dopamine", "midnight", "mint", "sunset", "aurora", "graphite"} ADMIN_SECTIONS = { @@ -118,6 +132,7 @@ ADMIN_SECTIONS = { "shop_orders", "points", "finance", + "seasons", } @@ -131,6 +146,17 @@ def readable_error(error_code: str) -> str: return ERROR_TEXTS.get(error_code, f"操作失败:{error_code}") +def _utc_day_key(now_iso: str = "") -> str: + if now_iso: + dt = datetime.fromisoformat(now_iso.replace("Z", "+00:00")) + else: + dt = datetime.now(timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + dt = dt.astimezone(timezone.utc) + return dt.strftime("%Y-%m-%d") + + def normalize_theme(theme_value: Any, default: str = "dopamine") -> str: theme = str(theme_value or "").strip().lower() if theme in ALLOWED_THEMES: @@ -900,6 +926,7 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: defaults["linuxdo_oauth"] = dict(DEFAULT_SETTINGS.get("linuxdo_oauth", {})) defaults["role_badges"] = default_role_badges() defaults["points_system"] = dict(DEFAULT_SETTINGS.get("points_system", {})) + defaults["season_system"] = dict(DEFAULT_SETTINGS.get("season_system", {})) try: payload = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): @@ -919,6 +946,11 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: merged["points_system"] = {**defaults["points_system"], **raw_points} else: merged["points_system"] = dict(defaults["points_system"]) + raw_season = payload.get("season_system", {}) + if isinstance(raw_season, dict): + merged["season_system"] = {**defaults["season_system"], **raw_season} + else: + merged["season_system"] = dict(defaults["season_system"]) if "role_badges" in payload: merged["role_badges"] = normalize_badge_definitions( payload.get("role_badges", []), @@ -992,6 +1024,44 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: "redeem_user_limit": _to_int(cfg.get("redeem_user_limit", 10), 10, 1, 100), } + def get_season_system_config() -> dict[str, Any]: + raw = load_runtime_settings().get("season_system", {}) + cfg = raw if isinstance(raw, dict) else {} + + def _to_int(value: Any, default: int, minimum: int, maximum: int) -> int: + try: + out = int(value) + except (TypeError, ValueError): + out = int(default) + return max(minimum, min(maximum, out)) + + refresh_hours_raw = cfg.get("refresh_hours", [0, 8, 18]) + refresh_hours: list[int] = [] + if isinstance(refresh_hours_raw, list): + for row in refresh_hours_raw[:8]: + try: + val = int(row) + except (TypeError, ValueError): + continue + val = max(0, min(23, val)) + if val not in refresh_hours: + refresh_hours.append(val) + refresh_hours.sort() + if not refresh_hours: + refresh_hours = [0, 8, 18] + + return { + "enabled": bool(cfg.get("enabled", True)), + "auto_create_enabled": bool(cfg.get("auto_create_enabled", True)), + "auto_generate_theme": bool(cfg.get("auto_generate_theme", True)), + "season_duration_days": _to_int(cfg.get("season_duration_days", 30), 30, 7, 120), + "rest_days": _to_int(cfg.get("rest_days", 3), 3, 0, 14), + "daily_card_count": _to_int(cfg.get("daily_card_count", 3), 3, 1, 8), + "refresh_hours": refresh_hours, + "manual_refresh_cost": _to_int(cfg.get("manual_refresh_cost", 60), 60, 0, 99999), + "premium_pass_price": _to_int(cfg.get("premium_pass_price", 680), 680, 0, 999999), + } + def oauth_error_redirect(message: str) -> Any: return redirect(url_for("login_page", oauth_error=message)) @@ -1072,6 +1142,17 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: row["card_title"] = card["title"] if card else "未知剧本" row["card_cover_url"] = card.get("cover_url", "") if card else "" can_view_points = bool(viewer_user_id and str(viewer_user_id) == str(user["user_id"])) + active_season = persistence.get_active_season() + season_profile = None + if active_season is not None: + try: + season_profile = persistence.get_or_create_user_season_profile( + user["user_id"], + str(active_season.get("season_id", "")).strip(), + ) + except ValueError: + season_profile = None + equipped_decorations = persistence.get_user_equipped_decorations(user["user_id"]) return { "user": serialize_public_user(user), "presence": presence, @@ -1080,6 +1161,11 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: "points": build_user_points_payload(user["user_id"]) if can_view_points else {"account": None, "new_rewards": []}, "achievements": achievements, "recent_rooms": recent_rooms, + "season": { + "active_season": active_season, + "profile": season_profile, + "equipped_decorations": equipped_decorations, + }, } def build_room_summary_payload(room_id: str) -> dict[str, Any]: @@ -1292,6 +1378,441 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: "updated_at": str(room.get("updated_at", "")).strip(), } + def build_auto_season_blueprint( + *, + theme_hint: str = "", + duration_days: int = 30, + pass_price: int = 680, + ) -> dict[str, Any]: + safe_theme = str(theme_hint or "").strip()[:40] or "迷雾电波" + now = datetime.now(timezone.utc) + start_at = now.isoformat() + end_at = (now + timedelta(days=max(7, min(120, int(duration_days))))).isoformat() + season_name = f"{safe_theme}赛季" + description = f"围绕“{safe_theme}”展开的连续故事赛季,包含每日刷新卡片、周常任务与通行证奖励。" + story_seed = f"城市边缘的信号塔持续发出异常广播,所有线索都指向{safe_theme}背后的真相。" + story_outline = ( + "第一阶段收集碎片情报,第二阶段完成多线挑战,第三阶段进入赛季终章并解锁纪念奖励。" + ) + announcements = [ + { + "title": f"{season_name} 开启公告", + "content": ( + f"{season_name} 现已开启。每日 3 次刷新活动卡片,完成任务可提升赛季等级并领取限定装扮。" + ), + } + ] + task_templates = [ + { + "task_type": "daily", + "title": "每日热身", + "description": "完成 1 局任意剧本。", + "event_key": "complete_game", + "target_value": 1, + "reward_points": 16, + "reward_xp": 36, + "reward_season_coin": 4, + "weight": 100, + "difficulty": "easy", + }, + { + "task_type": "daily", + "title": "行动日志", + "description": "累计提交 10 次行动。", + "event_key": "submit_action", + "target_value": 10, + "reward_points": 14, + "reward_xp": 34, + "reward_season_coin": 4, + "weight": 90, + "difficulty": "easy", + }, + { + "task_type": "weekly", + "title": "周常远征", + "description": "本周完成 5 局游戏。", + "event_key": "complete_game", + "target_value": 5, + "reward_points": 52, + "reward_xp": 120, + "reward_season_coin": 14, + "weight": 100, + "difficulty": "normal", + }, + { + "task_type": "season", + "title": "赛季主线", + "description": "整个赛季完成 35 局游戏。", + "event_key": "complete_game", + "target_value": 35, + "reward_points": 220, + "reward_xp": 480, + "reward_season_coin": 80, + "weight": 100, + "difficulty": "hard", + }, + ] + card_pools = [ + { + "card_type": "daily", + "title_hint": f"{safe_theme}·晨雾", + "story_hook": "无线电频道出现重复坐标,你需要确认来源。", + "objective_template": "完成 1 局并记录关键事件。", + "hint_template": "优先完成基础目标,稳拿收益。", + "event_key": "complete_game", + "target_value": 1, + "reward_points": 12, + "reward_xp": 28, + "rarity": "normal", + "weight": 120, + }, + { + "card_type": "rotating", + "title_hint": f"{safe_theme}·回廊", + "story_hook": "平行片段开始互相干扰,你需要同步多方线索。", + "objective_template": "累计提交 8 次行动。", + "hint_template": "组队可更快完成目标。", + "event_key": "submit_action", + "target_value": 8, + "reward_points": 20, + "reward_xp": 52, + "rarity": "rare", + "weight": 88, + }, + { + "card_type": "surprise", + "title_hint": f"{safe_theme}·暗潮", + "story_hook": "隐藏档案突然解密,风险和收益同时提升。", + "objective_template": "赢下 1 局并保持存活。", + "hint_template": "建议协作推进,提高成功率。", + "event_key": "win_game", + "target_value": 1, + "reward_points": 50, + "reward_xp": 120, + "rarity": "epic", + "weight": 20, + }, + ] + pass_rewards: list[dict[str, Any]] = [] + for level in range(1, 31): + pass_rewards.append( + { + "reward_level": level, + "reward_track": "free", + "title": f"免费档 Lv.{level}", + "reward_points": 10 if level % 5 else 26, + "reward_xp": 0, + "reward_season_coin": 2 if level % 3 == 0 else 0, + "reward_payload": {"titles": []}, + } + ) + payload = {} + if level == 15: + payload = {"avatar_frames": ["season_auto_frame_epic"]} + if level == 30: + payload = { + "titles": ["season_auto_title_legend"], + "avatar_frames": ["season_auto_frame_legend"], + } + pass_rewards.append( + { + "reward_level": level, + "reward_track": "premium", + "title": f"高级档 Lv.{level}", + "reward_points": 18 if level % 5 else 58, + "reward_xp": 0, + "reward_season_coin": 6 if level % 2 == 0 else 3, + "reward_payload": payload, + } + ) + return { + "season": { + "name": season_name, + "theme": safe_theme, + "description": description, + "cover_url": "/static/img/cards/fogharbor.svg", + "accent_color": "#4f6dff", + "story_seed": story_seed, + "story_outline": story_outline, + "start_at": start_at, + "end_at": end_at, + "status": "active", + "source": "ai_auto", + "pass_price": max(0, int(pass_price)), + "config": { + "duration_days": max(7, min(120, int(duration_days))), + "daily_card_count": 3, + "weekly_task_count": 3, + "daily_task_count": 3, + "refresh_hours": [0, 8, 18], + "manual_refresh_cost": 60, + "xp_per_level": 100, + }, + }, + "announcements": announcements, + "task_templates": task_templates, + "card_pools": card_pools, + "pass_rewards": pass_rewards, + "titles": [ + {"title_id": "season_auto_title_legend", "name": f"{safe_theme}传奇", "color_code": "#ffd166", "rarity": "legendary"}, + ], + "avatar_frames": [ + {"frame_id": "season_auto_frame_epic", "name": f"{safe_theme}回响框", "rarity": "epic"}, + {"frame_id": "season_auto_frame_legend", "name": f"{safe_theme}王冠框", "rarity": "legendary"}, + ], + } + + def build_ai_daily_card_payload(season: dict[str, Any], *, day_key: str) -> dict[str, Any] | None: + cfg = season.get("config", {}) + if not isinstance(cfg, dict) or not cfg.get("ai_daily_card_enabled"): + return None + theme = str(season.get("theme", "")).strip()[:80] or "赛季主题" + season_name = str(season.get("name", "")).strip()[:80] or theme + tone = str(cfg.get("ai_daily_card_tone", "")).strip() or "悬疑冒险" + difficulty = str(cfg.get("ai_daily_card_difficulty", "")).strip() or "中等" + prompt_extra = str(cfg.get("ai_daily_card_prompt_extra", "")).strip()[:400] + cover_url = str(season.get("cover_url", "")).strip()[:720] or "/static/img/cards/fogharbor.svg" + schema_hint = { + "title": "string", + "subtitle": "string", + "summary": "string", + "opening_text": "string", + "starter_goals": ["string", "string", "string"], + "metrics": [{"key": "string", "label": "string", "min": 0, "max": 100, "initial": 0, "color": "#RRGGBB"}], + "status_tracks": [{"key": "string", "label": "string", "min": 0, "max": 100, "color": "#RRGGBB"}], + "hero_status_label": "string", + "hero_status_color": "#RRGGBB", + "genre_tags": ["string"], + "feature_badges": ["string"], + "world_lore": "string", + "core_conflict": "string", + "npc_roster": ["string"], + "scene_blueprint": ["string"], + "ending_catalog": ["string"], + "cover_url": "string", + "room_background_url": "string", + } + messages = [ + { + "role": "system", + "content": ( + "你是多人联机文字冒险的高级策划。" + "只输出一个合法 JSON 对象,不要输出 markdown 或解释。" + "内容使用中文,要求是完整可游玩的剧本卡。" + ), + }, + { + "role": "user", + "content": ( + f"赛季名称:{season_name}\n" + f"赛季主题:{theme}\n" + f"日期:{day_key}\n" + f"赛季背景:{str(season.get('story_seed', '')).strip()}\n" + f"赛季大纲:{str(season.get('story_outline', '')).strip()}\n" + f"风格:{tone}\n难度:{difficulty}\n" + f"{'额外要求:' + prompt_extra if prompt_extra else ''}\n" + "输出必须包含以下字段:\n" + f"{json.dumps(schema_hint, ensure_ascii=False)}\n" + "约束:starter_goals 3 条;metrics 2-4 条;status_tracks 1-3 条;" + "genre_tags 2-6 条;feature_badges 2-6 条;" + "world_lore 与 core_conflict 必须是完整中文段落。" + ), + }, + ] + ai_result = deepseek_client.chat_json(messages, temperature=0.7, max_tokens=1800) + if not ai_result.get("ok"): + return None + draft = parse_json_object(ai_result.get("content", "")) + if not isinstance(draft, dict): + return None + if not str(draft.get("cover_url", "")).strip(): + draft["cover_url"] = cover_url + if not str(draft.get("room_background_url", "")).strip(): + draft["room_background_url"] = cover_url + try: + return build_card_config(draft) + except ValueError: + return None + + def get_or_create_season_daily_card(season_id: str) -> dict[str, Any]: + season = persistence.get_season(season_id) + if season is None: + raise ValueError("season_not_found") + day_key = _utc_day_key() + existing = persistence.get_season_daily_game_card(season_id, day_key=day_key) + if existing is not None: + cfg = season.get("config", {}) + ai_enabled = bool(isinstance(cfg, dict) and cfg.get("ai_daily_card_enabled")) + if ai_enabled: + card_id = str(existing.get("card_id", "")).strip() + card = persistence.get_game_card(card_id) if card_id else None + legacy_mark = False + if card: + legacy_mark = ( + "赛季日卡" in str(card.get("title", "")) + or "页脚" in str(card.get("opening_text", "")) + or any("解锁一条资料" in str(g) for g in (card.get("starter_goals") or [])) + ) + if legacy_mark: + ai_payload = build_ai_daily_card_payload(season, day_key=day_key) + if ai_payload: + try: + persistence.update_game_card( + card_id, + title=ai_payload["title"], + subtitle=ai_payload["subtitle"], + summary=ai_payload["summary"], + cover_url=ai_payload["cover_url"], + opening_text=ai_payload["opening_text"], + starter_goals=ai_payload["starter_goals"], + state_schema=ai_payload["state_schema"], + content_pack=ai_payload["content_pack"], + ) + except ValueError: + pass + return existing + ai_payload = build_ai_daily_card_payload(season, day_key=day_key) + if ai_payload: + return persistence.ensure_season_daily_game_card(season_id, card_payload=ai_payload, source="ai") + return persistence.ensure_season_daily_game_card(season_id) + + def ensure_active_season() -> dict[str, Any] | None: + season_cfg = get_season_system_config() + if not season_cfg.get("enabled", True): + return None + active = persistence.get_active_season() + if active is not None: + persistence.ensure_season_start_announcement(str(active.get("season_id", "")).strip()) + return active + if not season_cfg.get("auto_create_enabled", True): + return None + theme_hint = "迷雾电波" if season_cfg.get("auto_generate_theme", True) else "标准赛季" + blueprint = build_auto_season_blueprint( + theme_hint=theme_hint, + duration_days=int(season_cfg.get("season_duration_days", 30)), + pass_price=int(season_cfg.get("premium_pass_price", 680)), + ) + season_payload = blueprint.get("season", {}) if isinstance(blueprint, dict) else {} + if not isinstance(season_payload, dict): + return None + try: + created = persistence.create_season( + name=str(season_payload.get("name", "自动赛季")), + theme=str(season_payload.get("theme", "")), + description=str(season_payload.get("description", "")), + cover_url=str(season_payload.get("cover_url", "")), + accent_color=str(season_payload.get("accent_color", "#4f6dff")), + story_seed=str(season_payload.get("story_seed", "")), + story_outline=str(season_payload.get("story_outline", "")), + start_at=str(season_payload.get("start_at", "")), + end_at=str(season_payload.get("end_at", "")), + status=str(season_payload.get("status", "active")), + source=str(season_payload.get("source", "ai_auto")), + pass_price=int(season_payload.get("pass_price", 680) or 680), + config=season_payload.get("config", {}) if isinstance(season_payload.get("config"), dict) else {}, + created_by="system", + ) + except ValueError: + return persistence.get_active_season() + season_id = str(created.get("season_id", "")).strip() + for item in blueprint.get("announcements", []): + if not isinstance(item, dict): + continue + try: + persistence.create_season_announcement( + season_id, + title=str(item.get("title", "赛季公告")), + content=str(item.get("content", "")), + created_by="system", + ) + except ValueError: + continue + for item in blueprint.get("task_templates", []): + if not isinstance(item, dict): + continue + try: + persistence.upsert_season_task_template(season_id, item) + except ValueError: + continue + for item in blueprint.get("card_pools", []): + if not isinstance(item, dict): + continue + try: + persistence.upsert_season_card_pool(season_id, item) + except ValueError: + continue + for item in blueprint.get("pass_rewards", []): + if not isinstance(item, dict): + continue + try: + persistence.upsert_season_pass_reward(season_id, item) + except ValueError: + continue + for idx, item in enumerate(blueprint.get("titles", [])): + if not isinstance(item, dict): + continue + try: + persistence.upsert_season_task_template( + season_id, + { + "task_type": "achievement", + "title": str(item.get("name", "赛季称号")), + "description": "隐藏成就占位模板,可在后台继续扩展。", + "event_key": "complete_game", + "target_value": 999999, + "reward_payload": {"titles": [str(item.get("title_id", ""))]}, + "is_active": False, + "sort_order": 900 + idx, + }, + ) + except ValueError: + continue + return persistence.get_active_season() + + def build_active_season_payload(user_id: str) -> dict[str, Any]: + active = ensure_active_season() + rest = persistence.get_season_by_time_status("rest") + season = active or rest + if season is None: + raise ValueError("season_not_found") + season_id = str(season.get("season_id", "")).strip() + profile = persistence.get_or_create_user_season_profile(user_id, season_id) + persistence.ensure_user_season_tasks(user_id, season_id) + cards_bundle = persistence.ensure_user_season_cards(user_id, season_id) if str(season.get("time_status")) == "running" else {"cards": [], "refresh_key": "", "next_refresh_at": "", "generated": False} + announcements = persistence.list_season_announcements(season_id, limit=10) + tasks = persistence.list_user_season_tasks(user_id, season_id, limit=1200) + rewards = persistence.list_season_pass_rewards(season_id, limit=1200) + claimed_reward_ids = persistence.list_user_season_reward_claims(user_id, season_id) + leaderboard = persistence.get_season_leaderboard(season_id, limit=20) + decorations = persistence.list_user_decorations(user_id, limit=400) + equipped = persistence.get_user_equipped_decorations(user_id) + daily_game_card = get_or_create_season_daily_card(season_id) if str(season.get("time_status")) in {"running", "rest"} else {} + intel = persistence.list_user_season_intel(user_id, season_id, limit=400) + next_refresh_at = str(cards_bundle.get("next_refresh_at", "")).strip() + if not next_refresh_at and cards_bundle.get("cards"): + first_card = cards_bundle.get("cards", [])[0] if isinstance(cards_bundle.get("cards"), list) else {} + if isinstance(first_card, dict): + next_refresh_at = str(first_card.get("expires_at", "")).strip() + return { + "season": season, + "active_season": active, + "rest_season": rest, + "profile": profile, + "announcements": announcements, + "tasks": tasks, + "cards": cards_bundle.get("cards", []), + "refresh_key": cards_bundle.get("refresh_key", ""), + "next_refresh_at": next_refresh_at, + "daily_game_card": daily_game_card, + "intel": intel, + "rewards": rewards, + "claimed_reward_ids": sorted(claimed_reward_ids), + "leaderboard": leaderboard, + "decorations": decorations, + "equipped_decorations": equipped, + "system_config": get_season_system_config(), + } + def pick_unique_username(base_username: str, provider_user_id: str) -> str: base = normalize_account_username(base_username) or "linuxdo_user" if persistence.get_user_by_username(base) is None: @@ -1341,6 +1862,54 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: return redirect(url_for("login_page")) return render_template("store.html", user=serialize_user(user)) + @app.get("/season") + def season_page() -> Any: + user = current_user() + if user is None: + return redirect(url_for("login_page")) + return render_template("season.html", user=serialize_user(user)) + + @app.get("/season/chronicle") + def season_chronicle_page() -> Any: + user = current_user() + if user is None: + return redirect(url_for("login_page")) + return render_template("season_chronicle.html", user=serialize_user(user)) + + @app.get("/season//chronicle") + def season_chronicle_view_page(season_id: str) -> Any: + user = current_user() + if user is None: + return redirect(url_for("login_page")) + safe_season_id = str(season_id or "").strip()[:80] + if not safe_season_id: + return redirect(url_for("season_page")) + return render_template( + "season_chronicle.html", + user=serialize_user(user), + ) + + @app.get("/season//cards") + def season_cards_page(season_id: str) -> Any: + user = current_user() + if user is None: + return redirect(url_for("login_page")) + safe_season_id = str(season_id or "").strip()[:80] + if not safe_season_id: + return redirect(url_for("season_page")) + return render_template( + "season_cards.html", + user=serialize_user(user), + season_id=safe_season_id, + ) + + @app.get("/admin/seasons") + def season_admin_page() -> Any: + user = current_user() + if user is None or str(user.get("role", "player")) != "admin": + return redirect(url_for("index")) + return render_template("season_admin.html", user=serialize_user(user)) + @app.get("/users/") def user_profile_page(username: str) -> str: safe_username = normalize_account_username(username) @@ -1709,6 +2278,340 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: rows = persistence.list_user_entitlements(user["user_id"], entitlement_type=entitlement_type, status="active", limit=400) return jsonify({"ok": True, "entitlements": rows}) + @app.get("/api/seasons/active") + def active_season_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + try: + payload = build_active_season_payload(user["user_id"]) + return jsonify({"ok": True, **payload}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 404 + + @app.get("/api/seasons/history") + def season_history_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + rows = persistence.list_seasons(limit=40) + return jsonify({"ok": True, "seasons": rows}) + + @app.get("/api/seasons//cards") + def season_cards_api(season_id: str) -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + safe_season_id = str(season_id or "").strip()[:80] + if not safe_season_id: + return jsonify({"ok": False, "error": readable_error("invalid_card_id")}), 400 + season = persistence.get_season(safe_season_id) + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + cards = persistence.list_game_cards(world_id=f"season:{safe_season_id}") + return jsonify({"ok": True, "season": season, "season_id": safe_season_id, "cards": cards}) + + @app.get("/api/seasons/active/daily-card") + def active_season_daily_game_card_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() or persistence.get_season_by_time_status("rest") + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + try: + daily = get_or_create_season_daily_card(season_id) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + return jsonify({"ok": True, "season_id": season_id, "daily_game_card": daily}) + + @app.get("/api/seasons/active/chronicle") + def active_season_chronicle_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() or persistence.get_season_by_time_status("rest") + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + try: + items = persistence.build_season_chronicle_items(user["user_id"], season_id, limit_days=90) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + return jsonify({"ok": True, "season": season, "season_id": season_id, "items": items}) + + @app.get("/api/seasons//chronicle") + def season_chronicle_api(season_id: str) -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + safe_season_id = str(season_id or "").strip()[:80] + if not safe_season_id: + return jsonify({"ok": False, "error": readable_error("invalid_card_id")}), 400 + season = persistence.get_season(safe_season_id) + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + try: + items = persistence.build_season_chronicle_items(user["user_id"], safe_season_id, limit_days=180) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + return jsonify({"ok": True, "season": season, "season_id": safe_season_id, "items": items}) + + @app.post("/api/seasons/active/intel/unlock") + def active_season_intel_unlock_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() or persistence.get_season_by_time_status("rest") + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + payload = request.get_json(silent=True) or {} + day_key = str(payload.get("day_key", "")).strip() + intel_key = str(payload.get("intel_key", "")).strip() + if not day_key: + return jsonify({"ok": False, "error": readable_error("invalid_card_payload")}), 400 + try: + unlocked = persistence.unlock_season_intel(user["user_id"], season_id, day_key=day_key, intel_key=intel_key) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + return jsonify({"ok": True, "season_id": season_id, "unlocked": unlocked}) + + @app.get("/api/seasons/active/intel") + def active_season_intel_list_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() or persistence.get_season_by_time_status("rest") + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + rows = persistence.list_user_season_intel(user["user_id"], season_id, limit=800) + return jsonify({"ok": True, "season_id": season_id, "intel": rows}) + + @app.post("/api/seasons/active/pass/unlock") + def unlock_active_season_pass_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + try: + profile = persistence.unlock_user_season_pass(user["user_id"], season_id) + return jsonify({"ok": True, "profile": profile, "season_id": season_id}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.get("/api/seasons/active/tasks") + def active_season_tasks_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + task_type = str(request.args.get("type", "")).strip().lower() + persistence.ensure_user_season_tasks(user["user_id"], season_id) + tasks = persistence.list_user_season_tasks( + user["user_id"], + season_id, + task_type=task_type, + include_claimed=True, + limit=1200, + ) + return jsonify({"ok": True, "season_id": season_id, "tasks": tasks}) + + @app.post("/api/seasons/active/tasks/progress") + def active_season_tasks_progress_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + payload = request.get_json(silent=True) or {} + event_key = str(payload.get("event_key", "")).strip().lower() + amount_raw = payload.get("amount", 1) + context = payload.get("context", {}) + try: + amount = max(1, int(amount_raw)) + except (TypeError, ValueError): + amount = 1 + if not event_key: + return jsonify({"ok": False, "error": readable_error("invalid_card_payload")}), 400 + task_result = persistence.progress_user_season_tasks( + user["user_id"], + season_id, + event_key=event_key, + amount=amount, + context=context if isinstance(context, dict) else {}, + ) + card_result = persistence.progress_user_season_cards( + user["user_id"], + season_id, + event_key=event_key, + amount=amount, + ) + return jsonify({"ok": True, "season_id": season_id, "task_result": task_result, "card_result": card_result}) + + @app.post("/api/seasons/active/tasks//claim") + def active_season_task_claim_api(task_id: int) -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + try: + result = persistence.claim_user_season_task(user["user_id"], season_id, task_id) + return jsonify({"ok": True, "result": result}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.post("/api/seasons/active/tasks/claim-all") + def active_season_tasks_claim_all_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + result = persistence.claim_all_user_season_tasks(user["user_id"], season_id) + return jsonify({"ok": True, "result": result}) + + @app.get("/api/seasons/active/cards") + def active_season_cards_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + bundle = persistence.ensure_user_season_cards(user["user_id"], season_id) + cards = persistence.list_user_season_cards( + user["user_id"], + season_id, + refresh_key=str(bundle.get("refresh_key", "")), + include_expired=False, + limit=200, + ) + return jsonify( + { + "ok": True, + "season_id": season_id, + "refresh_key": bundle.get("refresh_key", ""), + "next_refresh_at": bundle.get("next_refresh_at", ""), + "generated": bool(bundle.get("generated", False)), + "cards": cards, + } + ) + + @app.post("/api/seasons/active/cards//claim") + def active_season_card_claim_api(card_id: int) -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + try: + result = persistence.claim_user_season_card(user["user_id"], season_id, card_id) + return jsonify({"ok": True, "result": result}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.post("/api/seasons/active/cards//refresh") + def active_season_card_refresh_api(card_id: int) -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + payload = request.get_json(silent=True) or {} + req_cost_raw = payload.get("cost") + season_cfg = get_season_system_config() + refresh_cost = int(season_cfg.get("manual_refresh_cost", 60)) + if req_cost_raw is not None: + try: + refresh_cost = max(0, int(req_cost_raw)) + except (TypeError, ValueError): + refresh_cost = int(season_cfg.get("manual_refresh_cost", 60)) + try: + result = persistence.refresh_user_season_card( + user["user_id"], + season_id, + card_id, + refresh_cost=refresh_cost, + ) + return jsonify({"ok": True, "result": result, "refresh_cost": refresh_cost}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.get("/api/seasons/active/rewards") + def active_season_rewards_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + profile = persistence.get_or_create_user_season_profile(user["user_id"], season_id) + rewards = persistence.list_season_pass_rewards(season_id, limit=1600) + claimed = sorted(persistence.list_user_season_reward_claims(user["user_id"], season_id)) + return jsonify({"ok": True, "season_id": season_id, "profile": profile, "rewards": rewards, "claimed_reward_ids": claimed}) + + @app.post("/api/seasons/active/rewards//claim") + def active_season_reward_claim_api(reward_id: str) -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + season = ensure_active_season() + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + season_id = str(season.get("season_id", "")).strip() + try: + result = persistence.claim_season_pass_reward(user["user_id"], season_id, reward_id.strip()) + return jsonify({"ok": True, "result": result}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.get("/api/seasons/decorations") + def season_decorations_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + rows = persistence.list_user_decorations(user["user_id"], limit=600) + equipped = persistence.get_user_equipped_decorations(user["user_id"]) + return jsonify({"ok": True, "decorations": rows, "equipped": equipped}) + + @app.post("/api/seasons/decorations/equip") + def season_decorations_equip_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + payload = request.get_json(silent=True) or {} + decoration_type = str(payload.get("decoration_type", "")).strip().lower() + decoration_id = str(payload.get("decoration_id", "")).strip() + if decoration_type not in {"title", "avatar_frame"} or not decoration_id: + return jsonify({"ok": False, "error": readable_error("invalid_card_payload")}), 400 + try: + equipped = persistence.equip_user_decoration(user["user_id"], decoration_type, decoration_id) + return jsonify({"ok": True, "equipped": equipped}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + @app.get("/api/store/items") def store_items_api() -> Any: user = current_user() @@ -2138,7 +3041,7 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: @app.get("/api/cards") def list_cards_api() -> Any: - cards = persistence.list_game_cards() + cards = persistence.list_game_cards(world_id="global") user = current_user() price_map = persistence.get_game_card_price_map_for_user(user.get("user_id") if user else None) rows: list[dict[str, Any]] = [] @@ -2562,6 +3465,327 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: summary = persistence.get_finance_summary(days=days) return jsonify({"ok": True, "summary": summary}) + @app.get("/api/admin/settings/season-system") + def admin_season_system_get_api() -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + return jsonify({"ok": True, "config": get_season_system_config()}) + + @app.patch("/api/admin/settings/season-system") + def admin_season_system_patch_api() -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + payload = request.get_json(silent=True) or {} + if not isinstance(payload, dict): + payload = {} + current_raw = load_settings_file_payload() + defaults = get_season_system_config() + next_cfg = dict(defaults) + for key in ( + "enabled", + "auto_create_enabled", + "auto_generate_theme", + "season_duration_days", + "daily_card_count", + "refresh_hours", + "manual_refresh_cost", + "premium_pass_price", + ): + if key in payload: + next_cfg[key] = payload.get(key) + current_raw["season_system"] = next_cfg + save_runtime_settings(current_raw) + return jsonify({"ok": True, "config": get_season_system_config()}) + + @app.get("/api/admin/seasons") + def admin_seasons_api() -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + rows = persistence.list_seasons(limit=80) + return jsonify({"ok": True, "seasons": rows, "active": persistence.get_active_season()}) + + @app.post("/api/admin/seasons/auto-generate") + def admin_auto_generate_season_api() -> Any: + user, error_resp = require_admin_json() + if error_resp: + return error_resp + payload = request.get_json(silent=True) or {} + season_cfg = get_season_system_config() + theme_hint = str(payload.get("theme_hint", "")).strip()[:40] or "迷雾电波" + duration_days = int(payload.get("duration_days", season_cfg.get("season_duration_days", 30)) or 30) + pass_price = int(payload.get("pass_price", season_cfg.get("premium_pass_price", 680)) or 680) + blueprint = build_auto_season_blueprint( + theme_hint=theme_hint, + duration_days=duration_days, + pass_price=pass_price, + ) + season_payload = blueprint.get("season", {}) if isinstance(blueprint, dict) else {} + try: + season = persistence.create_season( + name=str(season_payload.get("name", "自动赛季")), + theme=str(season_payload.get("theme", "")), + description=str(season_payload.get("description", "")), + cover_url=str(season_payload.get("cover_url", "")), + accent_color=str(season_payload.get("accent_color", "#4f6dff")), + story_seed=str(season_payload.get("story_seed", "")), + story_outline=str(season_payload.get("story_outline", "")), + start_at=str(season_payload.get("start_at", "")), + end_at=str(season_payload.get("end_at", "")), + status=str(season_payload.get("status", "active")), + source=str(season_payload.get("source", "ai_auto")), + pass_price=int(season_payload.get("pass_price", 680) or 680), + config=season_payload.get("config", {}) if isinstance(season_payload.get("config"), dict) else {}, + created_by=str(user.get("user_id", "")), + ) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + season_id = str(season.get("season_id", "")).strip() + for item in blueprint.get("announcements", []): + if isinstance(item, dict): + try: + persistence.create_season_announcement( + season_id, + title=str(item.get("title", "赛季公告")), + content=str(item.get("content", "")), + created_by=str(user.get("user_id", "")), + ) + except ValueError: + pass + for item in blueprint.get("task_templates", []): + if isinstance(item, dict): + try: + persistence.upsert_season_task_template(season_id, item) + except ValueError: + pass + for item in blueprint.get("card_pools", []): + if isinstance(item, dict): + try: + persistence.upsert_season_card_pool(season_id, item) + except ValueError: + pass + for item in blueprint.get("pass_rewards", []): + if isinstance(item, dict): + try: + persistence.upsert_season_pass_reward(season_id, item) + except ValueError: + pass + for item in blueprint.get("titles", []): + if isinstance(item, dict): + try: + persistence.upsert_season_title(season_id, item, title_id=str(item.get("title_id", ""))) + except ValueError: + pass + for item in blueprint.get("avatar_frames", []): + if isinstance(item, dict): + try: + persistence.upsert_season_avatar_frame(season_id, item, frame_id=str(item.get("frame_id", ""))) + except ValueError: + pass + return jsonify({"ok": True, "season": persistence.get_season(season_id), "blueprint": blueprint}) + + @app.post("/api/admin/seasons") + def admin_create_season_api() -> Any: + user, error_resp = require_admin_json() + if error_resp: + return error_resp + payload = request.get_json(silent=True) or {} + if not isinstance(payload, dict): + payload = {} + try: + season = persistence.create_season( + name=str(payload.get("name", "")), + theme=str(payload.get("theme", "")), + description=str(payload.get("description", "")), + cover_url=str(payload.get("cover_url", "")), + accent_color=str(payload.get("accent_color", "#4f6dff")), + story_seed=str(payload.get("story_seed", "")), + story_outline=str(payload.get("story_outline", "")), + start_at=str(payload.get("start_at", "")), + end_at=str(payload.get("end_at", "")), + status=str(payload.get("status", "active")), + source=str(payload.get("source", "manual")), + pass_price=int(payload.get("pass_price", 680) or 680), + config=payload.get("config", {}) if isinstance(payload.get("config"), dict) else {}, + created_by=str(user.get("user_id", "")), + season_id=str(payload.get("season_id", "")), + ) + return jsonify({"ok": True, "season": season}) + except (TypeError, ValueError) as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.patch("/api/admin/seasons/") + def admin_update_season_api(season_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + payload = request.get_json(silent=True) or {} + try: + season = persistence.update_season(season_id.strip(), payload if isinstance(payload, dict) else {}) + return jsonify({"ok": True, "season": season}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.get("/api/admin/seasons//dashboard") + def admin_season_dashboard_api(season_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + season = persistence.get_season(season_id.strip()) + if season is None: + return jsonify({"ok": False, "error": readable_error("season_not_found")}), 404 + dashboard = persistence.get_season_dashboard(season_id.strip()) + return jsonify({"ok": True, "season": season, "dashboard": dashboard}) + + @app.post("/api/admin/seasons//announce") + def admin_season_announce_api(season_id: str) -> Any: + user, error_resp = require_admin_json() + if error_resp: + return error_resp + payload = request.get_json(silent=True) or {} + title = str(payload.get("title", "")).strip() + content = str(payload.get("content", "")).strip() + if not title: + return jsonify({"ok": False, "error": readable_error("invalid_card_payload")}), 400 + try: + row = persistence.create_season_announcement( + season_id.strip(), + title=title, + content=content, + created_by=str(user.get("user_id", "")), + ) + return jsonify({"ok": True, "announcement": row}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.get("/api/admin/seasons//tasks") + def admin_season_tasks_api(season_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + task_type = str(request.args.get("type", "")).strip().lower() + rows = persistence.list_season_task_templates(season_id.strip(), task_type=task_type, active_only=False, limit=1200) + return jsonify({"ok": True, "task_templates": rows}) + + @app.post("/api/admin/seasons//tasks") + def admin_season_task_upsert_api(season_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + payload = request.get_json(silent=True) or {} + template_id = str(payload.get("template_id", "")).strip() + try: + row = persistence.upsert_season_task_template( + season_id.strip(), + payload if isinstance(payload, dict) else {}, + template_id=template_id, + ) + return jsonify({"ok": True, "task_template": row}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.get("/api/admin/seasons//card-pools") + def admin_season_card_pools_api(season_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + rows = persistence.list_season_card_pools(season_id.strip(), active_only=False, limit=1200) + return jsonify({"ok": True, "card_pools": rows}) + + @app.post("/api/admin/seasons//card-pools") + def admin_season_card_pool_upsert_api(season_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + payload = request.get_json(silent=True) or {} + pool_id = str(payload.get("pool_id", "")).strip() + try: + row = persistence.upsert_season_card_pool( + season_id.strip(), + payload if isinstance(payload, dict) else {}, + pool_id=pool_id, + ) + return jsonify({"ok": True, "card_pool": row}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.get("/api/admin/seasons//pass-rewards") + def admin_season_pass_rewards_api(season_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + rows = persistence.list_season_pass_rewards(season_id.strip(), limit=2400) + return jsonify({"ok": True, "pass_rewards": rows}) + + @app.post("/api/admin/seasons//pass-rewards") + def admin_season_pass_reward_upsert_api(season_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + payload = request.get_json(silent=True) or {} + reward_id = str(payload.get("reward_id", "")).strip() + try: + row = persistence.upsert_season_pass_reward( + season_id.strip(), + payload if isinstance(payload, dict) else {}, + reward_id=reward_id, + ) + return jsonify({"ok": True, "pass_reward": row}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.get("/api/admin/seasons//titles") + def admin_season_titles_api(season_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + rows = persistence.list_season_titles(season_id.strip(), active_only=False, limit=1200) + return jsonify({"ok": True, "titles": rows}) + + @app.post("/api/admin/seasons//titles") + def admin_season_title_upsert_api(season_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + payload = request.get_json(silent=True) or {} + title_id = str(payload.get("title_id", "")).strip() + try: + row = persistence.upsert_season_title( + season_id.strip(), + payload if isinstance(payload, dict) else {}, + title_id=title_id, + ) + return jsonify({"ok": True, "title": row}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.get("/api/admin/seasons//avatar-frames") + def admin_season_avatar_frames_api(season_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + rows = persistence.list_season_avatar_frames(season_id.strip(), active_only=False, limit=1200) + return jsonify({"ok": True, "avatar_frames": rows}) + + @app.post("/api/admin/seasons//avatar-frames") + def admin_season_avatar_frame_upsert_api(season_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + payload = request.get_json(silent=True) or {} + frame_id = str(payload.get("frame_id", "")).strip() + try: + row = persistence.upsert_season_avatar_frame( + season_id.strip(), + payload if isinstance(payload, dict) else {}, + frame_id=frame_id, + ) + return jsonify({"ok": True, "avatar_frame": row}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + @app.get("/api/admin/settings/oauth/linuxdo") def admin_linuxdo_oauth_get_api() -> Any: _, error_resp = require_admin_json() @@ -3622,6 +4846,10 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: summary = build_room_summary_payload(safe_room_id) except ValueError: return jsonify({"ok": False, "error": readable_error("room_not_found")}), 404 + user = current_user() + if user and str(summary.get("status", "")).strip().lower() == "ended": + season_progress = persistence.apply_room_summary_season_progress(user["user_id"], summary) + summary["season_progress"] = season_progress return jsonify({"ok": True, "summary": summary}) @socketio.on("join_room_socket") diff --git a/config.py b/config.py index 10ed216..a9f21fd 100644 --- a/config.py +++ b/config.py @@ -35,6 +35,17 @@ DEFAULT_SETTINGS: dict[str, Any] = { "redeem_ip_limit": 12, "redeem_user_limit": 10, }, + "season_system": { + "enabled": True, + "auto_create_enabled": True, + "auto_generate_theme": True, + "season_duration_days": 30, + "rest_days": 3, + "daily_card_count": 3, + "refresh_hours": [0, 8, 18], + "manual_refresh_cost": 60, + "premium_pass_price": 680, + }, "app_host": "0.0.0.0", "app_port": 8095, "debug": True, diff --git a/services/persistence.py b/services/persistence.py index f9063bd..ad0df1e 100644 --- a/services/persistence.py +++ b/services/persistence.py @@ -81,6 +81,41 @@ ACHIEVEMENT_POINT_REWARD_TRACKS: list[dict[str, Any]] = [ ], }, ] +SEASON_XP_PER_LEVEL = 100 +SEASON_MAX_LEVEL = 120 +SEASON_TASK_TYPES = {"daily", "weekly", "season", "achievement"} +SEASON_CARD_RARITY_MULTIPLIER = { + "normal": 1.0, + "rare": 1.35, + "epic": 1.85, + "legendary": 2.6, +} +SEASON_STORY_OPENERS = [ + "潮雾在天台翻涌,远处的霓虹像被潮汐擦亮。", + "失真的广播再次响起,仿佛有人在幕布后低声提醒。", + "旧档案的封条被悄悄揭开,纸页边缘还留着余温。", + "钟摆越过整点时,街区的光影突然反向流动。", +] +SEASON_STORY_TWISTS = [ + "你上次留下的选择在今天产生了反作用。", + "一位沉默角色开始主动与你交换线索。", + "原本安全的路径被新的规则临时封锁。", + "看似无关的碎片在本轮首次拼接成完整图案。", +] +SEASON_HINT_POOL = [ + "优先完成一次稳定推进,先拿到基础奖励。", + "如果队伍在线,尝试组队类目标,效率更高。", + "本卡目标可跨局累积,不必在一局内硬冲。", + "保留一次刷新机会,可能刷出高价值惊喜卡。", +] + + +def _utc_day_key(now_iso: str = "") -> str: + dt = datetime.fromisoformat((now_iso or utc_now_iso()).replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + dt = dt.astimezone(timezone.utc) + return dt.strftime("%Y-%m-%d") class SQLitePersistence: @@ -120,6 +155,7 @@ class SQLitePersistence: CREATE TABLE IF NOT EXISTS game_cards ( card_id TEXT PRIMARY KEY, + world_id TEXT NOT NULL DEFAULT 'global', title TEXT NOT NULL, subtitle TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL, @@ -385,6 +421,309 @@ class SQLitePersistence: UNIQUE(user_id, reward_key), FOREIGN KEY(user_id) REFERENCES users(user_id) ); + + CREATE TABLE IF NOT EXISTS seasons ( + season_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + theme TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + cover_url TEXT NOT NULL DEFAULT '', + accent_color TEXT NOT NULL DEFAULT '#4f6dff', + story_seed TEXT NOT NULL DEFAULT '', + story_outline TEXT NOT NULL DEFAULT '', + start_at TEXT NOT NULL, + end_at TEXT NOT NULL, + rest_end_at TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + source TEXT NOT NULL DEFAULT 'manual', + pass_price INTEGER NOT NULL DEFAULT 680, + created_by TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS season_announcements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + FOREIGN KEY(season_id) REFERENCES seasons(season_id) + ); + + CREATE TABLE IF NOT EXISTS user_season_profiles ( + user_id TEXT NOT NULL, + season_id TEXT NOT NULL, + pass_type TEXT NOT NULL DEFAULT 'free', + level INTEGER NOT NULL DEFAULT 1, + xp INTEGER NOT NULL DEFAULT 0, + season_points INTEGER NOT NULL DEFAULT 0, + total_claimed_cards INTEGER NOT NULL DEFAULT 0, + completed_tasks INTEGER NOT NULL DEFAULT 0, + last_refresh_key TEXT NOT NULL DEFAULT '', + last_generated_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(user_id, season_id), + FOREIGN KEY(user_id) REFERENCES users(user_id), + FOREIGN KEY(season_id) REFERENCES seasons(season_id) + ); + + CREATE TABLE IF NOT EXISTS season_story_threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + season_id TEXT NOT NULL, + thread_summary TEXT NOT NULL DEFAULT '', + thread_state_json TEXT NOT NULL DEFAULT '{}', + last_generated_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL, + UNIQUE(user_id, season_id), + FOREIGN KEY(user_id) REFERENCES users(user_id), + FOREIGN KEY(season_id) REFERENCES seasons(season_id) + ); + + CREATE TABLE IF NOT EXISTS season_activity_cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + season_id TEXT NOT NULL, + refresh_key TEXT NOT NULL, + title TEXT NOT NULL, + story_text TEXT NOT NULL DEFAULT '', + objective TEXT NOT NULL DEFAULT '', + hint_text TEXT NOT NULL DEFAULT '', + reward_points INTEGER NOT NULL DEFAULT 0, + reward_xp INTEGER NOT NULL DEFAULT 0, + rarity TEXT NOT NULL DEFAULT 'normal', + status TEXT NOT NULL DEFAULT 'active', + claimed_at TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + claimed_tx_id TEXT NOT NULL DEFAULT '', + FOREIGN KEY(user_id) REFERENCES users(user_id), + FOREIGN KEY(season_id) REFERENCES seasons(season_id) + ); + + CREATE TABLE IF NOT EXISTS season_task_templates ( + template_id TEXT PRIMARY KEY, + season_id TEXT NOT NULL, + task_type TEXT NOT NULL DEFAULT 'daily', + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + event_key TEXT NOT NULL DEFAULT '', + target_value INTEGER NOT NULL DEFAULT 1, + difficulty TEXT NOT NULL DEFAULT 'normal', + weight INTEGER NOT NULL DEFAULT 100, + reward_points INTEGER NOT NULL DEFAULT 0, + reward_xp INTEGER NOT NULL DEFAULT 0, + reward_season_coin INTEGER NOT NULL DEFAULT 0, + reward_payload_json TEXT NOT NULL DEFAULT '{}', + constraints_json TEXT NOT NULL DEFAULT '{}', + refresh_policy TEXT NOT NULL DEFAULT 'auto', + is_active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(season_id) REFERENCES seasons(season_id) + ); + + CREATE TABLE IF NOT EXISTS user_season_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + season_id TEXT NOT NULL, + template_id TEXT NOT NULL, + task_type TEXT NOT NULL DEFAULT 'daily', + refresh_key TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + event_key TEXT NOT NULL DEFAULT '', + target_value INTEGER NOT NULL DEFAULT 1, + progress_value INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + reward_points INTEGER NOT NULL DEFAULT 0, + reward_xp INTEGER NOT NULL DEFAULT 0, + reward_season_coin INTEGER NOT NULL DEFAULT 0, + reward_payload_json TEXT NOT NULL DEFAULT '{}', + constraints_json TEXT NOT NULL DEFAULT '{}', + expires_at TEXT NOT NULL DEFAULT '', + claimed_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, season_id, template_id, refresh_key), + FOREIGN KEY(user_id) REFERENCES users(user_id), + FOREIGN KEY(season_id) REFERENCES seasons(season_id), + FOREIGN KEY(template_id) REFERENCES season_task_templates(template_id) + ); + + CREATE TABLE IF NOT EXISTS season_pass_rewards ( + reward_id TEXT PRIMARY KEY, + season_id TEXT NOT NULL, + reward_level INTEGER NOT NULL DEFAULT 1, + reward_track TEXT NOT NULL DEFAULT 'free', + title TEXT NOT NULL DEFAULT '', + reward_points INTEGER NOT NULL DEFAULT 0, + reward_xp INTEGER NOT NULL DEFAULT 0, + reward_season_coin INTEGER NOT NULL DEFAULT 0, + reward_payload_json TEXT NOT NULL DEFAULT '{}', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(season_id, reward_level, reward_track, sort_order), + FOREIGN KEY(season_id) REFERENCES seasons(season_id) + ); + + CREATE TABLE IF NOT EXISTS user_season_reward_claims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + season_id TEXT NOT NULL, + reward_id TEXT NOT NULL, + claimed_at TEXT NOT NULL, + UNIQUE(user_id, season_id, reward_id), + FOREIGN KEY(user_id) REFERENCES users(user_id), + FOREIGN KEY(season_id) REFERENCES seasons(season_id), + FOREIGN KEY(reward_id) REFERENCES season_pass_rewards(reward_id) + ); + + CREATE TABLE IF NOT EXISTS season_card_pools ( + pool_id TEXT PRIMARY KEY, + season_id TEXT NOT NULL, + card_type TEXT NOT NULL DEFAULT 'rotating', + title_hint TEXT NOT NULL DEFAULT '', + story_hook TEXT NOT NULL DEFAULT '', + objective_template TEXT NOT NULL DEFAULT '', + hint_template TEXT NOT NULL DEFAULT '', + event_key TEXT NOT NULL DEFAULT '', + target_value INTEGER NOT NULL DEFAULT 1, + reward_points INTEGER NOT NULL DEFAULT 0, + reward_xp INTEGER NOT NULL DEFAULT 0, + rarity TEXT NOT NULL DEFAULT 'normal', + weight INTEGER NOT NULL DEFAULT 100, + min_level INTEGER NOT NULL DEFAULT 1, + payload_json TEXT NOT NULL DEFAULT '{}', + is_active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(season_id) REFERENCES seasons(season_id) + ); + + CREATE TABLE IF NOT EXISTS season_titles ( + title_id TEXT PRIMARY KEY, + season_id TEXT NOT NULL, + name TEXT NOT NULL, + color_code TEXT NOT NULL DEFAULT '#ffffff', + rarity TEXT NOT NULL DEFAULT 'normal', + acquire_type TEXT NOT NULL DEFAULT 'season_reward', + image_url TEXT NOT NULL DEFAULT '', + effect_json TEXT NOT NULL DEFAULT '{}', + is_active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(season_id) REFERENCES seasons(season_id) + ); + + CREATE TABLE IF NOT EXISTS season_avatar_frames ( + frame_id TEXT PRIMARY KEY, + season_id TEXT NOT NULL, + name TEXT NOT NULL, + style_json TEXT NOT NULL DEFAULT '{}', + rarity TEXT NOT NULL DEFAULT 'normal', + acquire_type TEXT NOT NULL DEFAULT 'season_reward', + preview_url TEXT NOT NULL DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(season_id) REFERENCES seasons(season_id) + ); + + CREATE TABLE IF NOT EXISTS user_decorations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + season_id TEXT NOT NULL DEFAULT '', + decoration_type TEXT NOT NULL, + decoration_id TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'season', + expires_at TEXT NOT NULL DEFAULT '', + is_equipped INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, decoration_type, decoration_id), + FOREIGN KEY(user_id) REFERENCES users(user_id) + ); + + CREATE TABLE IF NOT EXISTS season_room_progress_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + season_id TEXT NOT NULL, + room_id TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(user_id, season_id, room_id), + FOREIGN KEY(user_id) REFERENCES users(user_id), + FOREIGN KEY(season_id) REFERENCES seasons(season_id) + ); + + CREATE TABLE IF NOT EXISTS season_daily_game_cards ( + day_key TEXT NOT NULL, + season_id TEXT NOT NULL, + card_id TEXT NOT NULL, + title TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + guide_text TEXT NOT NULL DEFAULT '', + intro_text TEXT NOT NULL DEFAULT '', + locked_intel_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(day_key, season_id), + UNIQUE(season_id, card_id), + FOREIGN KEY(season_id) REFERENCES seasons(season_id), + FOREIGN KEY(card_id) REFERENCES game_cards(card_id) + ); + + CREATE TABLE IF NOT EXISTS user_season_intel_unlocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + season_id TEXT NOT NULL, + day_key TEXT NOT NULL, + intel_key TEXT NOT NULL, + intel_title TEXT NOT NULL DEFAULT '', + intel_text TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE(user_id, season_id, day_key, intel_key), + FOREIGN KEY(user_id) REFERENCES users(user_id), + FOREIGN KEY(season_id) REFERENCES seasons(season_id) + ); + + CREATE TABLE IF NOT EXISTS season_daily_card_completions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + season_id TEXT NOT NULL, + day_key TEXT NOT NULL, + room_id TEXT NOT NULL, + card_id TEXT NOT NULL, + ending_state TEXT NOT NULL DEFAULT '', + ending_summary TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE(user_id, season_id, day_key), + UNIQUE(user_id, season_id, room_id), + FOREIGN KEY(user_id) REFERENCES users(user_id), + FOREIGN KEY(season_id) REFERENCES seasons(season_id), + FOREIGN KEY(card_id) REFERENCES game_cards(card_id) + ); + + CREATE TABLE IF NOT EXISTS season_story_snapshots ( + season_id TEXT PRIMARY KEY, + guide_text TEXT NOT NULL DEFAULT '', + story_text TEXT NOT NULL DEFAULT '', + cards_count INTEGER NOT NULL DEFAULT 0, + intel_count INTEGER NOT NULL DEFAULT 0, + built_at TEXT NOT NULL, + FOREIGN KEY(season_id) REFERENCES seasons(season_id) + ); """ ) @@ -393,12 +732,21 @@ class SQLitePersistence: self._ensure_column(conn, "rooms", "is_public", "INTEGER NOT NULL DEFAULT 0") self._ensure_column(conn, "game_cards", "state_schema", "TEXT NOT NULL DEFAULT '{}'") self._ensure_column(conn, "game_cards", "content_pack", "TEXT NOT NULL DEFAULT '{}'") + self._ensure_column(conn, "game_cards", "world_id", "TEXT NOT NULL DEFAULT 'global'") + conn.execute("CREATE INDEX IF NOT EXISTS idx_game_cards_world_created ON game_cards(world_id, created_at DESC)") self._ensure_column(conn, "users", "role", "TEXT NOT NULL DEFAULT 'player'") self._ensure_column(conn, "users", "avatar_url", "TEXT NOT NULL DEFAULT ''") self._ensure_column(conn, "users", "theme", "TEXT NOT NULL DEFAULT 'dopamine'") self._ensure_column(conn, "users", "is_online", "INTEGER NOT NULL DEFAULT 0") self._ensure_column(conn, "users", "last_active_at", "TEXT NOT NULL DEFAULT ''") self._ensure_column(conn, "users", "active_badge", "TEXT NOT NULL DEFAULT ''") + self._ensure_column(conn, "seasons", "config_json", "TEXT NOT NULL DEFAULT '{}'") + self._ensure_column(conn, "seasons", "rest_end_at", "TEXT NOT NULL DEFAULT ''") + self._ensure_column(conn, "season_activity_cards", "event_key", "TEXT NOT NULL DEFAULT ''") + self._ensure_column(conn, "season_activity_cards", "target_value", "INTEGER NOT NULL DEFAULT 1") + self._ensure_column(conn, "season_activity_cards", "progress_value", "INTEGER NOT NULL DEFAULT 0") + self._ensure_column(conn, "season_activity_cards", "card_type", "TEXT NOT NULL DEFAULT 'rotating'") + self._ensure_column(conn, "season_activity_cards", "source_pool_id", "TEXT NOT NULL DEFAULT ''") conn.execute( "CREATE INDEX IF NOT EXISTS idx_invitations_to_status ON invitations(to_user_id, status, created_at DESC)" ) @@ -438,9 +786,58 @@ class SQLitePersistence: conn.execute( "CREATE INDEX IF NOT EXISTS idx_entitlements_user_type_status ON user_entitlements(user_id, entitlement_type, status, created_at DESC)" ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_seasons_status_time ON seasons(status, start_at, end_at)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_season_announce_season_created ON season_announcements(season_id, created_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_user_season_profile_updated ON user_season_profiles(season_id, updated_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_season_thread_user_season ON season_story_threads(user_id, season_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_season_cards_user_slot ON season_activity_cards(user_id, season_id, refresh_key, status, created_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_season_cards_status ON season_activity_cards(season_id, status, created_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_season_task_tpl_lookup ON season_task_templates(season_id, task_type, is_active, sort_order, updated_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_user_season_tasks_lookup ON user_season_tasks(user_id, season_id, task_type, status, refresh_key, created_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_user_season_tasks_event ON user_season_tasks(user_id, season_id, event_key, status)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_season_pass_reward_lookup ON season_pass_rewards(season_id, reward_level, reward_track, sort_order)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_season_reward_claims_user ON user_season_reward_claims(user_id, season_id, claimed_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_season_card_pool_lookup ON season_card_pools(season_id, card_type, is_active, weight, sort_order)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_user_decorations_lookup ON user_decorations(user_id, decoration_type, is_equipped, created_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_season_room_progress_unique ON season_room_progress_logs(user_id, season_id, room_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_season_daily_cards_season_created ON season_daily_game_cards(season_id, created_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_user_season_intel_user_season ON user_season_intel_unlocks(user_id, season_id, created_at DESC)" + ) conn.commit() self._seed_default_cards(conn) + self._seed_default_season_assets(conn) conn.commit() def _seed_default_cards(self, conn: sqlite3.Connection) -> None: @@ -820,6 +1217,392 @@ class SQLitePersistence: ), ) + def _seed_default_season_assets(self, conn: sqlite3.Connection) -> None: + now_dt = datetime.now(timezone.utc) + now = now_dt.isoformat() + count_row = conn.execute("SELECT COUNT(*) AS c FROM seasons").fetchone() + season_count = int(dict(count_row or {}).get("c", 0)) + season_id = "" + if season_count <= 0: + start_at = (now_dt - timedelta(days=1)).isoformat() + end_at = (now_dt + timedelta(days=29)).isoformat() + season_id = f"s{now_dt.strftime('%Y%m')}" + conn.execute( + """ + INSERT INTO seasons( + season_id, name, theme, description, cover_url, accent_color, story_seed, story_outline, + start_at, end_at, rest_end_at, status, source, pass_price, config_json, created_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + season_id, + "启航赛季", + "迷雾电波", + "首个赛季:在异常电波与雾夜都市中追踪真相碎片。", + "/static/img/cards/fogharbor.svg", + "#4f6dff", + "电波中反复提到一段被抹去的档案编号。", + "从零散异象入手,拼接一条完整的城市夜行叙事。", + start_at, + end_at, + (now_dt + timedelta(days=32)).isoformat(), + "active", + "seed", + 680, + json.dumps( + { + "duration_days": 30, + "rest_days": 3, + "daily_card_count": 3, + "refresh_hours": [0, 8, 18], + "manual_refresh_cost": 60, + "xp_per_level": SEASON_XP_PER_LEVEL, + }, + ensure_ascii=False, + ), + "system", + now, + now, + ), + ) + else: + row = conn.execute( + "SELECT season_id FROM seasons ORDER BY start_at DESC, created_at DESC LIMIT 1" + ).fetchone() + season_id = str(dict(row or {}).get("season_id", "")).strip() + if not season_id: + return + + announce_row = conn.execute( + "SELECT COUNT(*) AS c FROM season_announcements WHERE season_id=?", + (season_id,), + ).fetchone() + if int(dict(announce_row or {}).get("c", 0)) <= 0: + conn.execute( + """ + INSERT INTO season_announcements(season_id, title, content, created_by, created_at) + VALUES (?, ?, ?, ?, ?) + """, + ( + season_id, + "赛季开启公告", + "启航赛季现已开放:完成每日卡片、推进每周任务、冲击赛季等级,解锁限定称号与头像框。", + "system", + now, + ), + ) + + template_row = conn.execute( + "SELECT COUNT(*) AS c FROM season_task_templates WHERE season_id=?", + (season_id,), + ).fetchone() + if int(dict(template_row or {}).get("c", 0)) <= 0: + templates = [ + ( + f"{season_id}_daily_play", + "daily", + "热身对局", + "完成 1 局任意剧本。", + "complete_game", + 1, + "easy", + 100, + 16, + 36, + 4, + ), + ( + f"{season_id}_daily_action", + "daily", + "行动记录", + "累计提交 10 次行动。", + "submit_action", + 10, + "easy", + 95, + 14, + 34, + 4, + ), + ( + f"{season_id}_daily_win", + "daily", + "小队首胜", + "赢下 1 局游戏。", + "win_game", + 1, + "normal", + 88, + 18, + 44, + 5, + ), + ( + f"{season_id}_weekly_play", + "weekly", + "周常远征", + "本周完成 5 局游戏。", + "complete_game", + 5, + "normal", + 100, + 52, + 120, + 14, + ), + ( + f"{season_id}_weekly_win", + "weekly", + "周常捷报", + "本周获得 3 场胜利。", + "win_game", + 3, + "normal", + 92, + 68, + 156, + 18, + ), + ( + f"{season_id}_season_play", + "season", + "赛季长线", + "整个赛季累计完成 35 局游戏。", + "complete_game", + 35, + "hard", + 100, + 220, + 480, + 80, + ), + ( + f"{season_id}_season_mvp", + "season", + "核心贡献者", + "累计获得 8 次 MVP。", + "mvp_count", + 8, + "hard", + 90, + 250, + 520, + 90, + ), + ] + for idx, row in enumerate(templates): + conn.execute( + """ + INSERT INTO season_task_templates( + template_id, season_id, task_type, title, description, event_key, target_value, + difficulty, weight, reward_points, reward_xp, reward_season_coin, + reward_payload_json, constraints_json, refresh_policy, is_active, sort_order, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'auto', 1, ?, ?, ?) + """, + ( + row[0], + season_id, + row[1], + row[2], + row[3], + row[4], + row[5], + row[6], + row[7], + row[8], + row[9], + row[10], + "{}", + "{}", + idx, + now, + now, + ), + ) + + pool_row = conn.execute( + "SELECT COUNT(*) AS c FROM season_card_pools WHERE season_id=?", + (season_id,), + ).fetchone() + if int(dict(pool_row or {}).get("c", 0)) <= 0: + pools = [ + ( + f"{season_id}_daily_1", + "daily", + "雾幕巡查", + "追踪失真的无线电片段。", + "完成 1 局对局并记录关键线索。", + "优先完成基础目标,保留刷新次数。", + "complete_game", + 1, + 12, + 28, + "normal", + 120, + ), + ( + f"{season_id}_rotating_1", + "rotating", + "并行剧场", + "平行世界中的同一条街出现两套规则。", + "累计提交 8 次行动并触发一次团队协作。", + "如果队伍在线,优先冲刺协作玩法。", + "submit_action", + 8, + 20, + 52, + "rare", + 88, + ), + ( + f"{season_id}_surprise_1", + "surprise", + "暗潮来信", + "你收到一封未署名的旧案回执。", + "赢下 1 局并保持存活。", + "难度较高,建议组队尝试。", + "win_game", + 1, + 50, + 120, + "epic", + 20, + ), + ] + for idx, row in enumerate(pools): + conn.execute( + """ + INSERT INTO season_card_pools( + pool_id, season_id, card_type, title_hint, story_hook, objective_template, hint_template, + event_key, target_value, reward_points, reward_xp, rarity, weight, min_level, + payload_json, is_active, sort_order, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, '{}', 1, ?, ?, ?) + """, + ( + row[0], + season_id, + row[1], + row[2], + row[3], + row[4], + row[5], + row[6], + row[7], + row[8], + row[9], + row[10], + row[11], + idx, + now, + now, + ), + ) + + reward_row = conn.execute( + "SELECT COUNT(*) AS c FROM season_pass_rewards WHERE season_id=?", + (season_id,), + ).fetchone() + if int(dict(reward_row or {}).get("c", 0)) <= 0: + for level in range(1, 31): + free_reward_id = f"{season_id}_free_{level}" + premium_reward_id = f"{season_id}_premium_{level}" + free_points = 10 if level % 5 else 26 + premium_points = 18 if level % 5 else 58 + free_payload = {} + premium_payload = {} + if level == 8: + free_payload = {"titles": [f"{season_id}_title_pathfinder"]} + if level == 15: + premium_payload = {"avatar_frames": [f"{season_id}_frame_neon"]} + if level == 30: + premium_payload = { + "titles": [f"{season_id}_title_legend"], + "avatar_frames": [f"{season_id}_frame_legend"], + } + conn.execute( + """ + INSERT INTO season_pass_rewards( + reward_id, season_id, reward_level, reward_track, title, reward_points, reward_xp, + reward_season_coin, reward_payload_json, sort_order, created_at, updated_at + ) VALUES (?, ?, ?, 'free', ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + free_reward_id, + season_id, + level, + f"免费档 Lv.{level}", + free_points, + 0, + 2 if level % 3 == 0 else 0, + json.dumps(free_payload, ensure_ascii=False), + level, + now, + now, + ), + ) + conn.execute( + """ + INSERT INTO season_pass_rewards( + reward_id, season_id, reward_level, reward_track, title, reward_points, reward_xp, + reward_season_coin, reward_payload_json, sort_order, created_at, updated_at + ) VALUES (?, ?, ?, 'premium', ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + premium_reward_id, + season_id, + level, + f"高级档 Lv.{level}", + premium_points, + 0, + 6 if level % 2 == 0 else 3, + json.dumps(premium_payload, ensure_ascii=False), + level, + now, + now, + ), + ) + + title_row = conn.execute( + "SELECT COUNT(*) AS c FROM season_titles WHERE season_id=?", + (season_id,), + ).fetchone() + if int(dict(title_row or {}).get("c", 0)) <= 0: + title_defs = [ + (f"{season_id}_title_pathfinder", "雾幕先行者", "#9ec7ff", "rare"), + (f"{season_id}_title_legend", "电波传奇", "#ffd166", "legendary"), + ] + for idx, row in enumerate(title_defs): + conn.execute( + """ + INSERT INTO season_titles( + title_id, season_id, name, color_code, rarity, acquire_type, image_url, + effect_json, is_active, sort_order, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'season_reward', '', '{}', 1, ?, ?, ?) + """, + (row[0], season_id, row[1], row[2], row[3], idx, now, now), + ) + + frame_row = conn.execute( + "SELECT COUNT(*) AS c FROM season_avatar_frames WHERE season_id=?", + (season_id,), + ).fetchone() + if int(dict(frame_row or {}).get("c", 0)) <= 0: + frame_defs = [ + (f"{season_id}_frame_neon", "霓虹折线框", "rare"), + (f"{season_id}_frame_legend", "王冠回声框", "legendary"), + ] + for idx, row in enumerate(frame_defs): + conn.execute( + """ + INSERT INTO season_avatar_frames( + frame_id, season_id, name, style_json, rarity, acquire_type, preview_url, + is_active, sort_order, created_at, updated_at + ) VALUES (?, ?, ?, '{}', ?, 'season_reward', '', 1, ?, ?, ?) + """, + (row[0], season_id, row[1], row[2], idx, now, now), + ) + def create_room( self, room_id: str, @@ -2611,6 +3394,2896 @@ class SQLitePersistence: }, } + def _json_object(self, raw_value: Any) -> dict[str, Any]: + if isinstance(raw_value, dict): + return raw_value + if not isinstance(raw_value, str): + return {} + try: + payload = json.loads(raw_value) + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} + + def _json_list(self, raw_value: Any) -> list[Any]: + if isinstance(raw_value, list): + return raw_value + if not isinstance(raw_value, str): + return [] + try: + payload = json.loads(raw_value) + except json.JSONDecodeError: + return [] + return payload if isinstance(payload, list) else [] + + def _season_status_by_time(self, row: dict[str, Any], *, now_iso: str = "") -> str: + now_text = str(now_iso or "").strip() or utc_now_iso() + start_at = str(row.get("start_at", "")).strip() + end_at = str(row.get("end_at", "")).strip() + rest_end_at = str(row.get("rest_end_at", "")).strip() + manual_status = str(row.get("status", "")).strip().lower() + if manual_status in {"settling", "结算中"}: + return "settling" + if manual_status in {"ended", "已结束", "archived"}: + return "ended" + if start_at and now_text < start_at: + return "upcoming" + if end_at and now_text >= end_at: + if rest_end_at and now_text < rest_end_at: + return "rest" + return "ended" + if manual_status in {"draft", "未开始"}: + return "upcoming" + return "running" + + def _build_season_payload(self, row: sqlite3.Row | dict[str, Any], *, now_iso: str = "") -> dict[str, Any]: + item = dict(row) + item["pass_price"] = self._to_int(item.get("pass_price", 680), 680, 0) + item["config"] = self._json_object(item.get("config_json", "{}")) + item["time_status"] = self._season_status_by_time(item, now_iso=now_iso) + return item + + def list_seasons(self, *, limit: int = 20) -> list[dict[str, Any]]: + safe_limit = max(1, min(200, int(limit))) + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT * + FROM seasons + ORDER BY start_at DESC, created_at DESC + LIMIT ? + """, + (safe_limit,), + ).fetchall() + now_iso = utc_now_iso() + return [self._build_season_payload(row, now_iso=now_iso) for row in rows] + + def get_season(self, season_id: str) -> dict[str, Any] | None: + safe_id = str(season_id or "").strip() + if not safe_id: + return None + with self._lock, self._connect() as conn: + row = conn.execute("SELECT * FROM seasons WHERE season_id=?", (safe_id,)).fetchone() + if row is None: + return None + return self._build_season_payload(row, now_iso=utc_now_iso()) + + def get_active_season(self) -> dict[str, Any] | None: + seasons = self.list_seasons(limit=24) + now_iso = utc_now_iso() + for row in seasons: + if self._season_status_by_time(row, now_iso=now_iso) == "running": + payload = dict(row) + payload["time_status"] = "running" + return payload + return None + + def get_season_by_time_status(self, time_status: str) -> dict[str, Any] | None: + safe_status = str(time_status or "").strip().lower() + if safe_status not in {"upcoming", "running", "rest", "ended", "settling"}: + return None + seasons = self.list_seasons(limit=24) + now_iso = utc_now_iso() + for row in seasons: + if self._season_status_by_time(row, now_iso=now_iso) == safe_status: + payload = dict(row) + payload["time_status"] = safe_status + return payload + return None + + def create_season( + self, + *, + name: str, + theme: str = "", + description: str = "", + cover_url: str = "", + accent_color: str = "#4f6dff", + story_seed: str = "", + story_outline: str = "", + start_at: str = "", + end_at: str = "", + status: str = "active", + source: str = "manual", + pass_price: int = 680, + config: dict[str, Any] | None = None, + created_by: str = "", + season_id: str = "", + ) -> dict[str, Any]: + safe_name = str(name or "").strip()[:80] + if not safe_name: + raise ValueError("invalid_card_payload") + safe_theme = str(theme or "").strip()[:80] + safe_description = str(description or "").strip()[:360] + safe_cover = str(cover_url or "").strip()[:720] + safe_accent = str(accent_color or "").strip()[:16] or "#4f6dff" + safe_story_seed = str(story_seed or "").strip()[:240] + safe_story_outline = str(story_outline or "").strip()[:1200] + safe_start = str(start_at or "").strip()[:40] or utc_now_iso() + safe_end = str(end_at or "").strip()[:40] + if not safe_end: + parsed = self._parse_iso_datetime(safe_start) + if parsed is None: + parsed = datetime.now(timezone.utc) + safe_end = (parsed + timedelta(days=30)).isoformat() + if safe_end <= safe_start: + raise ValueError("invalid_card_payload") + safe_config = config if isinstance(config, dict) else {} + rest_days = self._to_int(safe_config.get("rest_days", 0), 0, 0) + rest_end = "" + end_dt = self._parse_iso_datetime(safe_end) + if end_dt is not None and rest_days > 0: + rest_end = (end_dt + timedelta(days=rest_days)).isoformat() + safe_status = str(status or "active").strip().lower()[:24] or "active" + safe_source = str(source or "manual").strip().lower()[:24] or "manual" + safe_creator = str(created_by or "").strip()[:64] + safe_pass_price = self._to_int(pass_price, 680, 0) + safe_id = str(season_id or "").strip().lower()[:64] + if not safe_id: + seed = re.sub(r"[^a-z0-9]+", "-", safe_name.lower()).strip("-")[:36] or "season" + safe_id = f"{seed}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}"[:64] + + now = utc_now_iso() + with self._lock, self._connect() as conn: + exists = conn.execute("SELECT season_id FROM seasons WHERE season_id=?", (safe_id,)).fetchone() + if exists: + raise ValueError("invalid_card_payload") + conn.execute( + """ + INSERT INTO seasons( + season_id, name, theme, description, cover_url, accent_color, story_seed, story_outline, + start_at, end_at, rest_end_at, status, source, pass_price, config_json, created_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + safe_id, + safe_name, + safe_theme, + safe_description, + safe_cover, + safe_accent, + safe_story_seed, + safe_story_outline, + safe_start, + safe_end, + rest_end, + safe_status, + safe_source, + safe_pass_price, + json.dumps(safe_config, ensure_ascii=False), + safe_creator, + now, + now, + ), + ) + conn.commit() + season = self.get_season(safe_id) + if season is None: + raise ValueError("invalid_response") + return season + + def update_season(self, season_id: str, patch: dict[str, Any]) -> dict[str, Any]: + current = self.get_season(season_id) + if current is None: + raise ValueError("invalid_card_id") + payload = patch if isinstance(patch, dict) else {} + merged = dict(current) + for key, limit in ( + ("name", 80), + ("theme", 80), + ("description", 360), + ("cover_url", 720), + ("accent_color", 16), + ("story_seed", 240), + ("story_outline", 1200), + ("status", 24), + ("source", 24), + ("start_at", 40), + ("end_at", 40), + ): + if key in payload: + merged[key] = str(payload.get(key, "")).strip()[:limit] + if "pass_price" in payload: + merged["pass_price"] = self._to_int(payload.get("pass_price", merged.get("pass_price", 680)), 680, 0) + rest_end_from_payload = None + if "rest_end_at" in payload: + rest_end_from_payload = str(payload.get("rest_end_at", "")).strip()[:40] + merged["rest_end_at"] = rest_end_from_payload + config_patch = payload.get("config") + if isinstance(config_patch, dict): + current_cfg = current.get("config", {}) + merged["config"] = {**(current_cfg if isinstance(current_cfg, dict) else {}), **config_patch} + else: + merged["config"] = current.get("config", {}) + start_at = str(merged.get("start_at", "")).strip() + end_at = str(merged.get("end_at", "")).strip() + if start_at and end_at and end_at <= start_at: + raise ValueError("invalid_card_payload") + rest_days = self._to_int(merged.get("config", {}).get("rest_days", 0), 0, 0) + if rest_end_from_payload is None and rest_days > 0 and end_at: + end_dt = self._parse_iso_datetime(end_at) + if end_dt is not None: + merged["rest_end_at"] = (end_dt + timedelta(days=rest_days)).isoformat() + + with self._lock, self._connect() as conn: + conn.execute( + """ + UPDATE seasons + SET name=?, theme=?, description=?, cover_url=?, accent_color=?, story_seed=?, story_outline=?, + start_at=?, end_at=?, rest_end_at=?, status=?, source=?, pass_price=?, config_json=?, updated_at=? + WHERE season_id=? + """, + ( + str(merged.get("name", "")).strip()[:80], + str(merged.get("theme", "")).strip()[:80], + str(merged.get("description", "")).strip()[:360], + str(merged.get("cover_url", "")).strip()[:720], + str(merged.get("accent_color", "#4f6dff")).strip()[:16] or "#4f6dff", + str(merged.get("story_seed", "")).strip()[:240], + str(merged.get("story_outline", "")).strip()[:1200], + start_at, + end_at, + str(merged.get("rest_end_at", "")).strip()[:40], + str(merged.get("status", "active")).strip()[:24] or "active", + str(merged.get("source", "manual")).strip()[:24] or "manual", + self._to_int(merged.get("pass_price", 680), 680, 0), + json.dumps(merged.get("config", {}), ensure_ascii=False), + utc_now_iso(), + str(season_id or "").strip(), + ), + ) + conn.commit() + updated = self.get_season(season_id) + if updated is None: + raise ValueError("invalid_card_id") + return updated + + def list_season_announcements(self, season_id: str, *, limit: int = 20) -> list[dict[str, Any]]: + safe_id = str(season_id or "").strip() + if not safe_id: + return [] + safe_limit = max(1, min(200, int(limit))) + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT id, season_id, title, content, created_by, created_at + FROM season_announcements + WHERE season_id=? + ORDER BY created_at DESC, id DESC + LIMIT ? + """, + (safe_id, safe_limit), + ).fetchall() + return [dict(row) for row in rows] + + def create_season_announcement( + self, + season_id: str, + *, + title: str, + content: str = "", + created_by: str = "", + ) -> dict[str, Any]: + safe_id = str(season_id or "").strip() + if not safe_id or self.get_season(safe_id) is None: + raise ValueError("invalid_card_id") + safe_title = str(title or "").strip()[:120] + if not safe_title: + raise ValueError("invalid_card_payload") + safe_content = str(content or "").strip()[:2400] + safe_creator = str(created_by or "").strip()[:64] + now = utc_now_iso() + with self._lock, self._connect() as conn: + conn.execute( + """ + INSERT INTO season_announcements(season_id, title, content, created_by, created_at) + VALUES (?, ?, ?, ?, ?) + """, + (safe_id, safe_title, safe_content, safe_creator, now), + ) + row_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + conn.commit() + row = conn.execute( + """ + SELECT id, season_id, title, content, created_by, created_at + FROM season_announcements + WHERE id=? + """, + (row_id,), + ).fetchone() + return dict(row) if row else {} + + def ensure_season_start_announcement(self, season_id: str) -> None: + safe_id = str(season_id or "").strip() + if not safe_id: + return + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT COUNT(*) AS c FROM season_announcements WHERE season_id=?", + (safe_id,), + ).fetchone() + if int(dict(row or {}).get("c", 0)) > 0: + return + season = conn.execute("SELECT name, theme FROM seasons WHERE season_id=?", (safe_id,)).fetchone() + season_row = dict(season or {}) + title = f"{season_row.get('name', '新赛季')} 开启公告" + content = ( + f"欢迎进入 {season_row.get('name', '新赛季')}。\n" + f"主题:{season_row.get('theme', '未命名主题')}\n" + "每日刷新活动卡片已上线,完成任务可提升赛季等级并领取限定奖励。" + ) + conn.execute( + """ + INSERT INTO season_announcements(season_id, title, content, created_by, created_at) + VALUES (?, ?, ?, 'system', ?) + """, + (safe_id, title[:120], content[:2400], utc_now_iso()), + ) + conn.commit() + + def _season_level_from_xp(self, xp_value: Any, *, xp_per_level: int = SEASON_XP_PER_LEVEL) -> int: + safe_xp = self._to_int(xp_value, 0, 0) + per_level = max(20, int(xp_per_level)) + level = safe_xp // per_level + 1 + return max(1, min(SEASON_MAX_LEVEL, level)) + + def _season_profile_payload(self, row: sqlite3.Row | dict[str, Any], *, season: dict[str, Any] | None = None) -> dict[str, Any]: + item = dict(row) + xp = self._to_int(item.get("xp", 0), 0, 0) + season_cfg = (season or {}).get("config", {}) if isinstance(season, dict) else {} + if not isinstance(season_cfg, dict): + season_cfg = {} + xp_per_level = self._to_int(season_cfg.get("xp_per_level", SEASON_XP_PER_LEVEL), SEASON_XP_PER_LEVEL, 20) + level = max( + self._to_int(item.get("level", 1), 1, 1), + self._season_level_from_xp(xp, xp_per_level=xp_per_level), + ) + level_start_xp = (level - 1) * xp_per_level + next_level_xp = level * xp_per_level + return { + "user_id": str(item.get("user_id", "")).strip(), + "season_id": str(item.get("season_id", "")).strip(), + "pass_type": str(item.get("pass_type", "free")).strip() or "free", + "level": level, + "xp": xp, + "xp_per_level": xp_per_level, + "xp_in_current_level": max(0, xp - level_start_xp), + "xp_to_next_level": max(0, next_level_xp - xp), + "season_points": self._to_int(item.get("season_points", 0), 0, 0), + "total_claimed_cards": self._to_int(item.get("total_claimed_cards", 0), 0, 0), + "completed_tasks": self._to_int(item.get("completed_tasks", 0), 0, 0), + "last_refresh_key": str(item.get("last_refresh_key", "")).strip(), + "last_generated_at": str(item.get("last_generated_at", "")).strip(), + "created_at": str(item.get("created_at", "")).strip(), + "updated_at": str(item.get("updated_at", "")).strip(), + } + + def get_or_create_user_season_profile(self, user_id: str, season_id: str) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + if not safe_user_id or not safe_season_id: + raise ValueError("user_not_found") + if self.get_user_by_id(safe_user_id) is None: + raise ValueError("user_not_found") + season = self.get_season(safe_season_id) + if season is None: + raise ValueError("invalid_card_id") + now = utc_now_iso() + with self._lock, self._connect() as conn: + conn.execute( + """ + INSERT INTO user_season_profiles( + user_id, season_id, pass_type, level, xp, season_points, total_claimed_cards, + completed_tasks, last_refresh_key, last_generated_at, created_at, updated_at + ) VALUES (?, ?, 'free', 1, 0, 0, 0, 0, '', '', ?, ?) + ON CONFLICT(user_id, season_id) DO NOTHING + """, + (safe_user_id, safe_season_id, now, now), + ) + row = conn.execute( + """ + SELECT user_id, season_id, pass_type, level, xp, season_points, total_claimed_cards, + completed_tasks, last_refresh_key, last_generated_at, created_at, updated_at + FROM user_season_profiles + WHERE user_id=? AND season_id=? + """, + (safe_user_id, safe_season_id), + ).fetchone() + conn.commit() + if row is None: + raise ValueError("invalid_response") + return self._season_profile_payload(row, season=season) + + def apply_user_season_gain( + self, + user_id: str, + season_id: str, + *, + xp_delta: int = 0, + season_points_delta: int = 0, + add_completed_task: bool = False, + add_claimed_card: bool = False, + ) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + season = self.get_season(safe_season_id) + if not safe_user_id or season is None: + raise ValueError("invalid_card_id") + profile = self.get_or_create_user_season_profile(safe_user_id, safe_season_id) + next_xp = self._to_int(profile.get("xp", 0), 0, 0) + self._to_int(xp_delta, 0, 0) + next_points = self._to_int(profile.get("season_points", 0), 0, 0) + self._to_int(season_points_delta, 0, 0) + next_completed = self._to_int(profile.get("completed_tasks", 0), 0, 0) + (1 if add_completed_task else 0) + next_claimed_cards = self._to_int(profile.get("total_claimed_cards", 0), 0, 0) + (1 if add_claimed_card else 0) + next_level = self._season_level_from_xp( + next_xp, + xp_per_level=self._to_int((season.get("config", {}) or {}).get("xp_per_level", SEASON_XP_PER_LEVEL), SEASON_XP_PER_LEVEL, 20), + ) + with self._lock, self._connect() as conn: + conn.execute( + """ + UPDATE user_season_profiles + SET level=?, xp=?, season_points=?, completed_tasks=?, total_claimed_cards=?, updated_at=? + WHERE user_id=? AND season_id=? + """, + ( + next_level, + next_xp, + next_points, + next_completed, + next_claimed_cards, + utc_now_iso(), + safe_user_id, + safe_season_id, + ), + ) + conn.commit() + return self.get_or_create_user_season_profile(safe_user_id, safe_season_id) + + def unlock_user_season_pass(self, user_id: str, season_id: str) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + season = self.get_season(safe_season_id) + if not safe_user_id or season is None: + raise ValueError("invalid_card_id") + profile = self.get_or_create_user_season_profile(safe_user_id, safe_season_id) + if str(profile.get("pass_type", "free")).strip() == "premium": + return profile + pass_price = self._to_int(season.get("pass_price", 680), 680, 0) + with self._lock, self._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + self._expire_point_lots_tx(conn, user_id=safe_user_id) + self._debit_points_tx( + conn, + safe_user_id, + pass_price, + tx_type="season_pass_unlock", + source="season", + related_id=safe_season_id, + note=f"解锁赛季通行证 {safe_season_id}", + count_spent=True, + ) + conn.execute( + """ + UPDATE user_season_profiles + SET pass_type='premium', updated_at=? + WHERE user_id=? AND season_id=? + """, + (utc_now_iso(), safe_user_id, safe_season_id), + ) + conn.commit() + return self.get_or_create_user_season_profile(safe_user_id, safe_season_id) + + def _build_season_task_template_payload(self, row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + item = dict(row) + item["target_value"] = self._to_int(item.get("target_value", 1), 1, 1) + item["weight"] = self._to_int(item.get("weight", 100), 100, 1) + item["reward_points"] = self._to_int(item.get("reward_points", 0), 0, 0) + item["reward_xp"] = self._to_int(item.get("reward_xp", 0), 0, 0) + item["reward_season_coin"] = self._to_int(item.get("reward_season_coin", 0), 0, 0) + item["sort_order"] = self._to_int(item.get("sort_order", 0), 0, 0) + item["constraints"] = self._json_object(item.get("constraints_json", "{}")) + item["reward_payload"] = self._json_object(item.get("reward_payload_json", "{}")) + item["is_active"] = bool(item.get("is_active", 0)) + return item + + def list_season_task_templates( + self, + season_id: str, + *, + task_type: str = "", + active_only: bool = False, + limit: int = 300, + ) -> list[dict[str, Any]]: + safe_season_id = str(season_id or "").strip() + if not safe_season_id: + return [] + safe_task_type = str(task_type or "").strip().lower() + safe_limit = max(1, min(2000, int(limit))) + where = ["season_id=?"] + params: list[Any] = [safe_season_id] + if safe_task_type: + where.append("task_type=?") + params.append(safe_task_type) + if active_only: + where.append("is_active=1") + where_sql = " AND ".join(where) + with self._lock, self._connect() as conn: + rows = conn.execute( + f""" + SELECT * + FROM season_task_templates + WHERE {where_sql} + ORDER BY sort_order ASC, updated_at DESC, created_at DESC + LIMIT ? + """, + tuple(params + [safe_limit]), + ).fetchall() + return [self._build_season_task_template_payload(row) for row in rows] + + def upsert_season_task_template( + self, + season_id: str, + payload: dict[str, Any], + *, + template_id: str = "", + ) -> dict[str, Any]: + safe_season_id = str(season_id or "").strip() + if not safe_season_id or self.get_season(safe_season_id) is None: + raise ValueError("invalid_card_id") + raw = payload if isinstance(payload, dict) else {} + safe_task_type = str(raw.get("task_type", "daily")).strip().lower() + if safe_task_type not in SEASON_TASK_TYPES: + safe_task_type = "daily" + safe_title = str(raw.get("title", "")).strip()[:84] + if not safe_title: + raise ValueError("invalid_card_payload") + safe_description = str(raw.get("description", "")).strip()[:240] + safe_event_key = str(raw.get("event_key", "")).strip().lower()[:48] + safe_target = self._to_int(raw.get("target_value", 1), 1, 1) + safe_difficulty = str(raw.get("difficulty", "normal")).strip().lower()[:24] or "normal" + safe_weight = self._to_int(raw.get("weight", 100), 100, 1) + safe_reward_points = self._to_int(raw.get("reward_points", 0), 0, 0) + safe_reward_xp = self._to_int(raw.get("reward_xp", 0), 0, 0) + safe_reward_coin = self._to_int(raw.get("reward_season_coin", 0), 0, 0) + safe_constraints = raw.get("constraints", {}) + safe_reward_payload = raw.get("reward_payload", {}) + safe_refresh_policy = str(raw.get("refresh_policy", "auto")).strip().lower()[:24] or "auto" + safe_active = bool(raw.get("is_active", True)) + safe_sort = self._to_int(raw.get("sort_order", 0), 0, 0) + now = utc_now_iso() + + safe_template_id = str(template_id or raw.get("template_id", "")).strip()[:80] + if not safe_template_id: + seed = re.sub(r"[^a-z0-9]+", "_", safe_title.lower()).strip("_")[:32] or "task" + safe_template_id = f"{safe_season_id}_{safe_task_type}_{seed}_{secrets.token_hex(3)}"[:80] + + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT template_id FROM season_task_templates WHERE template_id=?", + (safe_template_id,), + ).fetchone() + if row: + conn.execute( + """ + UPDATE season_task_templates + SET season_id=?, task_type=?, title=?, description=?, event_key=?, target_value=?, + difficulty=?, weight=?, reward_points=?, reward_xp=?, reward_season_coin=?, + reward_payload_json=?, constraints_json=?, refresh_policy=?, is_active=?, sort_order=?, + updated_at=? + WHERE template_id=? + """, + ( + safe_season_id, + safe_task_type, + safe_title, + safe_description, + safe_event_key, + safe_target, + safe_difficulty, + safe_weight, + safe_reward_points, + safe_reward_xp, + safe_reward_coin, + json.dumps(safe_reward_payload if isinstance(safe_reward_payload, dict) else {}, ensure_ascii=False), + json.dumps(safe_constraints if isinstance(safe_constraints, dict) else {}, ensure_ascii=False), + safe_refresh_policy, + 1 if safe_active else 0, + safe_sort, + now, + safe_template_id, + ), + ) + else: + conn.execute( + """ + INSERT INTO season_task_templates( + template_id, season_id, task_type, title, description, event_key, target_value, + difficulty, weight, reward_points, reward_xp, reward_season_coin, + reward_payload_json, constraints_json, refresh_policy, is_active, sort_order, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + safe_template_id, + safe_season_id, + safe_task_type, + safe_title, + safe_description, + safe_event_key, + safe_target, + safe_difficulty, + safe_weight, + safe_reward_points, + safe_reward_xp, + safe_reward_coin, + json.dumps(safe_reward_payload if isinstance(safe_reward_payload, dict) else {}, ensure_ascii=False), + json.dumps(safe_constraints if isinstance(safe_constraints, dict) else {}, ensure_ascii=False), + safe_refresh_policy, + 1 if safe_active else 0, + safe_sort, + now, + now, + ), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM season_task_templates WHERE template_id=?", + (safe_template_id,), + ).fetchone() + if row is None: + raise ValueError("invalid_response") + return self._build_season_task_template_payload(row) + + def _season_daily_key(self, now_text: str) -> str: + parsed = self._parse_iso_datetime(now_text) + if parsed is None: + parsed = datetime.now(timezone.utc) + return parsed.strftime("%Y%m%d") + + def _season_weekly_key(self, now_text: str) -> str: + parsed = self._parse_iso_datetime(now_text) + if parsed is None: + parsed = datetime.now(timezone.utc) + year, week, _ = parsed.isocalendar() + return f"{year}W{week:02d}" + + def _weighted_sample_rows(self, rows: list[dict[str, Any]], count: int) -> list[dict[str, Any]]: + if count <= 0 or not rows: + return [] + pool = list(rows) + selected: list[dict[str, Any]] = [] + while pool and len(selected) < count: + total = sum(max(1, int(item.get("weight", 1))) for item in pool) + pick = secrets.randbelow(max(1, total)) + 1 + cursor = 0 + chosen_idx = 0 + for idx, item in enumerate(pool): + cursor += max(1, int(item.get("weight", 1))) + if pick <= cursor: + chosen_idx = idx + break + selected.append(pool.pop(chosen_idx)) + return selected + + def _instantiate_user_tasks_tx( + self, + conn: sqlite3.Connection, + *, + user_id: str, + season_id: str, + task_type: str, + refresh_key: str, + count: int, + expires_at: str, + ) -> int: + existing_row = conn.execute( + """ + SELECT COUNT(*) AS c + FROM user_season_tasks + WHERE user_id=? AND season_id=? AND task_type=? AND refresh_key=? + """, + (user_id, season_id, task_type, refresh_key), + ).fetchone() + if int(dict(existing_row or {}).get("c", 0)) > 0: + return 0 + + template_rows = conn.execute( + """ + SELECT * + FROM season_task_templates + WHERE season_id=? AND task_type=? AND is_active=1 + ORDER BY sort_order ASC, weight DESC, updated_at DESC + """, + (season_id, task_type), + ).fetchall() + templates = [self._build_season_task_template_payload(row) for row in template_rows] + if not templates: + return 0 + if task_type in {"season", "achievement"}: + selected = templates + else: + selected = self._weighted_sample_rows(templates, max(1, count)) + now = utc_now_iso() + created = 0 + for template in selected: + conn.execute( + """ + INSERT OR IGNORE INTO user_season_tasks( + user_id, season_id, template_id, task_type, refresh_key, title, description, + event_key, target_value, progress_value, status, reward_points, reward_xp, reward_season_coin, + reward_payload_json, constraints_json, expires_at, claimed_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'active', ?, ?, ?, ?, ?, ?, '', ?, ?) + """, + ( + user_id, + season_id, + str(template.get("template_id", "")), + task_type, + refresh_key, + str(template.get("title", "")).strip()[:84], + str(template.get("description", "")).strip()[:240], + str(template.get("event_key", "")).strip().lower()[:48], + self._to_int(template.get("target_value", 1), 1, 1), + self._to_int(template.get("reward_points", 0), 0, 0), + self._to_int(template.get("reward_xp", 0), 0, 0), + self._to_int(template.get("reward_season_coin", 0), 0, 0), + json.dumps(template.get("reward_payload", {}), ensure_ascii=False), + json.dumps(template.get("constraints", {}), ensure_ascii=False), + expires_at, + now, + now, + ), + ) + created += int(conn.total_changes > 0) + return created + + def ensure_user_season_tasks(self, user_id: str, season_id: str) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + if not safe_user_id or not safe_season_id: + raise ValueError("user_not_found") + season = self.get_season(safe_season_id) + if season is None: + raise ValueError("invalid_card_id") + self.get_or_create_user_season_profile(safe_user_id, safe_season_id) + now_iso = utc_now_iso() + day_key = self._season_daily_key(now_iso) + week_key = self._season_weekly_key(now_iso) + parsed_now = self._parse_iso_datetime(now_iso) or datetime.now(timezone.utc) + day_expire = (parsed_now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + week_expire = (parsed_now + timedelta(days=8)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + + season_cfg = season.get("config", {}) + if not isinstance(season_cfg, dict): + season_cfg = {} + daily_count = self._to_int(season_cfg.get("daily_task_count", 3), 3, 1) + weekly_count = self._to_int(season_cfg.get("weekly_task_count", 3), 3, 1) + with self._lock, self._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + daily_created = self._instantiate_user_tasks_tx( + conn, + user_id=safe_user_id, + season_id=safe_season_id, + task_type="daily", + refresh_key=day_key, + count=daily_count, + expires_at=day_expire, + ) + weekly_created = self._instantiate_user_tasks_tx( + conn, + user_id=safe_user_id, + season_id=safe_season_id, + task_type="weekly", + refresh_key=week_key, + count=weekly_count, + expires_at=week_expire, + ) + season_created = self._instantiate_user_tasks_tx( + conn, + user_id=safe_user_id, + season_id=safe_season_id, + task_type="season", + refresh_key=safe_season_id, + count=40, + expires_at=str(season.get("end_at", "")).strip(), + ) + achievement_created = self._instantiate_user_tasks_tx( + conn, + user_id=safe_user_id, + season_id=safe_season_id, + task_type="achievement", + refresh_key=safe_season_id, + count=40, + expires_at="", + ) + conn.execute( + """ + UPDATE user_season_tasks + SET status='expired', updated_at=? + WHERE user_id=? AND season_id=? AND status='active' AND expires_at<>'' AND expires_at<=? + """, + (utc_now_iso(), safe_user_id, safe_season_id, utc_now_iso()), + ) + conn.commit() + return { + "daily_created": daily_created, + "weekly_created": weekly_created, + "season_created": season_created, + "achievement_created": achievement_created, + "day_key": day_key, + "week_key": week_key, + } + + def _build_user_season_task_payload(self, row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + item = dict(row) + target = self._to_int(item.get("target_value", 1), 1, 1) + progress = self._to_int(item.get("progress_value", 0), 0, 0) + item["target_value"] = target + item["progress_value"] = min(progress, target) + item["reward_points"] = self._to_int(item.get("reward_points", 0), 0, 0) + item["reward_xp"] = self._to_int(item.get("reward_xp", 0), 0, 0) + item["reward_season_coin"] = self._to_int(item.get("reward_season_coin", 0), 0, 0) + item["constraints"] = self._json_object(item.get("constraints_json", "{}")) + item["reward_payload"] = self._json_object(item.get("reward_payload_json", "{}")) + item["progress_percent"] = min(100, int(round((item["progress_value"] / target) * 100))) if target > 0 else 0 + return item + + def list_user_season_tasks( + self, + user_id: str, + season_id: str, + *, + task_type: str = "", + include_claimed: bool = True, + limit: int = 500, + ) -> list[dict[str, Any]]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + if not safe_user_id or not safe_season_id: + return [] + safe_task_type = str(task_type or "").strip().lower() + safe_limit = max(1, min(3000, int(limit))) + where = ["user_id=?", "season_id=?"] + params: list[Any] = [safe_user_id, safe_season_id] + if safe_task_type: + where.append("task_type=?") + params.append(safe_task_type) + if not include_claimed: + where.append("status<>'claimed'") + where_sql = " AND ".join(where) + with self._lock, self._connect() as conn: + rows = conn.execute( + f""" + SELECT * + FROM user_season_tasks + WHERE {where_sql} + ORDER BY + CASE status WHEN 'completed' THEN 0 WHEN 'active' THEN 1 WHEN 'claimed' THEN 2 ELSE 3 END, + task_type ASC, + created_at DESC, + id DESC + LIMIT ? + """, + tuple(params + [safe_limit]), + ).fetchall() + return [self._build_user_season_task_payload(row) for row in rows] + + def _task_constraints_match(self, task_constraints: dict[str, Any], context: dict[str, Any]) -> bool: + if not task_constraints: + return True + for key, expected in task_constraints.items(): + if key not in context: + return False + actual = context.get(key) + if isinstance(expected, list): + if actual not in expected: + return False + else: + if str(actual) != str(expected): + return False + return True + + def progress_user_season_tasks( + self, + user_id: str, + season_id: str, + *, + event_key: str, + amount: int = 1, + context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + safe_event_key = str(event_key or "").strip().lower() + safe_amount = self._to_int(amount, 1, 1) + ctx = context if isinstance(context, dict) else {} + if not safe_user_id or not safe_season_id or not safe_event_key: + return {"updated": [], "completed_ids": []} + self.ensure_user_season_tasks(safe_user_id, safe_season_id) + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT * + FROM user_season_tasks + WHERE user_id=? AND season_id=? AND event_key=? AND status='active' + ORDER BY id ASC + """, + (safe_user_id, safe_season_id, safe_event_key), + ).fetchall() + updated: list[dict[str, Any]] = [] + completed_ids: list[int] = [] + now = utc_now_iso() + for row in rows: + item = self._build_user_season_task_payload(row) + task_constraints = item.get("constraints", {}) + if not isinstance(task_constraints, dict): + task_constraints = {} + if not self._task_constraints_match(task_constraints, ctx): + continue + before = self._to_int(item.get("progress_value", 0), 0, 0) + target = self._to_int(item.get("target_value", 1), 1, 1) + after = min(target, before + safe_amount) + next_status = "completed" if after >= target else "active" + conn.execute( + """ + UPDATE user_season_tasks + SET progress_value=?, status=?, updated_at=? + WHERE id=? + """, + (after, next_status, now, int(item.get("id", 0))), + ) + fresh = dict(item) + fresh["progress_value"] = after + fresh["status"] = next_status + fresh["progress_percent"] = min(100, int(round((after / target) * 100))) if target > 0 else 0 + if next_status == "completed": + completed_ids.append(int(item.get("id", 0))) + updated.append(fresh) + conn.commit() + return {"updated": updated, "completed_ids": completed_ids} + + def _grant_reward_payload_tx( + self, + conn: sqlite3.Connection, + *, + user_id: str, + season_id: str, + reward_payload: dict[str, Any], + source: str, + ) -> list[dict[str, Any]]: + payload = reward_payload if isinstance(reward_payload, dict) else {} + granted: list[dict[str, Any]] = [] + now = utc_now_iso() + for title_id in payload.get("titles", []) if isinstance(payload.get("titles"), list) else []: + safe_id = str(title_id or "").strip()[:80] + if not safe_id: + continue + conn.execute( + """ + INSERT INTO user_decorations( + user_id, season_id, decoration_type, decoration_id, source, expires_at, + is_equipped, payload_json, created_at, updated_at + ) VALUES (?, ?, 'title', ?, ?, '', 0, '{}', ?, ?) + ON CONFLICT(user_id, decoration_type, decoration_id) + DO UPDATE SET updated_at=excluded.updated_at + """, + (user_id, season_id, safe_id, source[:32], now, now), + ) + granted.append({"decoration_type": "title", "decoration_id": safe_id}) + for frame_id in payload.get("avatar_frames", []) if isinstance(payload.get("avatar_frames"), list) else []: + safe_id = str(frame_id or "").strip()[:80] + if not safe_id: + continue + conn.execute( + """ + INSERT INTO user_decorations( + user_id, season_id, decoration_type, decoration_id, source, expires_at, + is_equipped, payload_json, created_at, updated_at + ) VALUES (?, ?, 'avatar_frame', ?, ?, '', 0, '{}', ?, ?) + ON CONFLICT(user_id, decoration_type, decoration_id) + DO UPDATE SET updated_at=excluded.updated_at + """, + (user_id, season_id, safe_id, source[:32], now, now), + ) + granted.append({"decoration_type": "avatar_frame", "decoration_id": safe_id}) + return granted + + def claim_user_season_task(self, user_id: str, season_id: str, task_id: int) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + safe_task_id = self._to_int(task_id, 0, 1) + if not safe_user_id or not safe_season_id or safe_task_id <= 0: + raise ValueError("invalid_card_payload") + profile_before = self.get_or_create_user_season_profile(safe_user_id, safe_season_id) + with self._lock, self._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + """ + SELECT * + FROM user_season_tasks + WHERE id=? AND user_id=? AND season_id=? + """, + (safe_task_id, safe_user_id, safe_season_id), + ).fetchone() + if row is None: + raise ValueError("invalid_card_id") + task = self._build_user_season_task_payload(row) + status = str(task.get("status", "active")).strip().lower() + if status == "claimed": + raise ValueError("shop_item_limit_reached") + if status != "completed": + raise ValueError("invalid_card_payload") + reward_points = self._to_int(task.get("reward_points", 0), 0, 0) + reward_xp = self._to_int(task.get("reward_xp", 0), 0, 0) + reward_coin = self._to_int(task.get("reward_season_coin", 0), 0, 0) + tx_payload = None + if reward_points > 0: + self._expire_point_lots_tx(conn, user_id=safe_user_id) + tx_payload = self._credit_points_tx( + conn, + safe_user_id, + reward_points, + tx_type="season_task_reward", + source="season", + related_id=f"{safe_season_id}:{safe_task_id}", + note=f"赛季任务奖励 #{safe_task_id}", + count_recharge=False, + create_lot=True, + allow_locked=True, + ) + granted = self._grant_reward_payload_tx( + conn, + user_id=safe_user_id, + season_id=safe_season_id, + reward_payload=task.get("reward_payload", {}), + source="task", + ) + claimed_at = utc_now_iso() + conn.execute( + """ + UPDATE user_season_tasks + SET status='claimed', claimed_at=?, updated_at=? + WHERE id=? + """, + (claimed_at, claimed_at, safe_task_id), + ) + conn.commit() + next_profile = self.apply_user_season_gain( + safe_user_id, + safe_season_id, + xp_delta=reward_xp, + season_points_delta=reward_coin, + add_completed_task=True, + ) + claimed_task = next((row for row in self.list_user_season_tasks(safe_user_id, safe_season_id, limit=800) if int(row.get("id", 0)) == safe_task_id), {}) + return { + "task": claimed_task, + "profile_before": profile_before, + "profile_after": next_profile, + "transaction": (tx_payload or {}).get("tx", {}), + "granted_decorations": granted, + } + + def claim_all_user_season_tasks(self, user_id: str, season_id: str) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + tasks = self.list_user_season_tasks(safe_user_id, safe_season_id, include_claimed=False, limit=1200) + completed_ids = [self._to_int(row.get("id", 0), 0, 1) for row in tasks if str(row.get("status", "")) == "completed"] + claimed: list[dict[str, Any]] = [] + for task_id in completed_ids: + try: + claimed.append(self.claim_user_season_task(safe_user_id, safe_season_id, task_id)) + except ValueError: + continue + profile = self.get_or_create_user_season_profile(safe_user_id, safe_season_id) + return {"claimed_count": len(claimed), "claimed": claimed, "profile": profile} + + def _build_season_pass_reward_payload(self, row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + item = dict(row) + item["reward_level"] = self._to_int(item.get("reward_level", 1), 1, 1) + item["reward_points"] = self._to_int(item.get("reward_points", 0), 0, 0) + item["reward_xp"] = self._to_int(item.get("reward_xp", 0), 0, 0) + item["reward_season_coin"] = self._to_int(item.get("reward_season_coin", 0), 0, 0) + item["sort_order"] = self._to_int(item.get("sort_order", 0), 0, 0) + item["reward_payload"] = self._json_object(item.get("reward_payload_json", "{}")) + return item + + def list_season_pass_rewards(self, season_id: str, *, limit: int = 800) -> list[dict[str, Any]]: + safe_season_id = str(season_id or "").strip() + if not safe_season_id: + return [] + safe_limit = max(1, min(4000, int(limit))) + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT * + FROM season_pass_rewards + WHERE season_id=? + ORDER BY reward_level ASC, reward_track ASC, sort_order ASC + LIMIT ? + """, + (safe_season_id, safe_limit), + ).fetchall() + return [self._build_season_pass_reward_payload(row) for row in rows] + + def upsert_season_pass_reward( + self, + season_id: str, + payload: dict[str, Any], + *, + reward_id: str = "", + ) -> dict[str, Any]: + safe_season_id = str(season_id or "").strip() + if not safe_season_id or self.get_season(safe_season_id) is None: + raise ValueError("invalid_card_id") + raw = payload if isinstance(payload, dict) else {} + safe_reward_level = self._to_int(raw.get("reward_level", 1), 1, 1) + safe_reward_track = str(raw.get("reward_track", "free")).strip().lower() + if safe_reward_track not in {"free", "premium"}: + safe_reward_track = "free" + safe_title = str(raw.get("title", f"{safe_reward_track} Lv.{safe_reward_level}")).strip()[:84] + safe_points = self._to_int(raw.get("reward_points", 0), 0, 0) + safe_xp = self._to_int(raw.get("reward_xp", 0), 0, 0) + safe_coin = self._to_int(raw.get("reward_season_coin", 0), 0, 0) + safe_sort = self._to_int(raw.get("sort_order", safe_reward_level), safe_reward_level, 0) + safe_payload = raw.get("reward_payload", {}) + safe_id = str(reward_id or raw.get("reward_id", "")).strip()[:80] + if not safe_id: + safe_id = f"{safe_season_id}_{safe_reward_track}_{safe_reward_level}_{secrets.token_hex(2)}"[:80] + now = utc_now_iso() + with self._lock, self._connect() as conn: + exists = conn.execute("SELECT reward_id FROM season_pass_rewards WHERE reward_id=?", (safe_id,)).fetchone() + if exists: + conn.execute( + """ + UPDATE season_pass_rewards + SET season_id=?, reward_level=?, reward_track=?, title=?, reward_points=?, reward_xp=?, + reward_season_coin=?, reward_payload_json=?, sort_order=?, updated_at=? + WHERE reward_id=? + """, + ( + safe_season_id, + safe_reward_level, + safe_reward_track, + safe_title, + safe_points, + safe_xp, + safe_coin, + json.dumps(safe_payload if isinstance(safe_payload, dict) else {}, ensure_ascii=False), + safe_sort, + now, + safe_id, + ), + ) + else: + conn.execute( + """ + INSERT INTO season_pass_rewards( + reward_id, season_id, reward_level, reward_track, title, reward_points, reward_xp, + reward_season_coin, reward_payload_json, sort_order, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + safe_id, + safe_season_id, + safe_reward_level, + safe_reward_track, + safe_title, + safe_points, + safe_xp, + safe_coin, + json.dumps(safe_payload if isinstance(safe_payload, dict) else {}, ensure_ascii=False), + safe_sort, + now, + now, + ), + ) + conn.commit() + row = conn.execute("SELECT * FROM season_pass_rewards WHERE reward_id=?", (safe_id,)).fetchone() + if row is None: + raise ValueError("invalid_response") + return self._build_season_pass_reward_payload(row) + + def list_user_season_reward_claims(self, user_id: str, season_id: str) -> set[str]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + if not safe_user_id or not safe_season_id: + return set() + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT reward_id + FROM user_season_reward_claims + WHERE user_id=? AND season_id=? + """, + (safe_user_id, safe_season_id), + ).fetchall() + return {str(dict(row).get("reward_id", "")).strip() for row in rows if str(dict(row).get("reward_id", "")).strip()} + + def claim_season_pass_reward(self, user_id: str, season_id: str, reward_id: str) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + safe_reward_id = str(reward_id or "").strip() + if not safe_user_id or not safe_season_id or not safe_reward_id: + raise ValueError("invalid_card_payload") + profile = self.get_or_create_user_season_profile(safe_user_id, safe_season_id) + rewards = self.list_season_pass_rewards(safe_season_id, limit=2000) + reward = next((row for row in rewards if str(row.get("reward_id", "")) == safe_reward_id), None) + if reward is None: + raise ValueError("invalid_card_id") + if safe_reward_id in self.list_user_season_reward_claims(safe_user_id, safe_season_id): + raise ValueError("shop_item_limit_reached") + if self._to_int(profile.get("level", 1), 1, 1) < self._to_int(reward.get("reward_level", 1), 1, 1): + raise ValueError("invalid_card_payload") + if str(reward.get("reward_track", "free")).strip() == "premium" and str(profile.get("pass_type", "free")) != "premium": + raise ValueError("card_purchase_required") + + with self._lock, self._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + reward_points = self._to_int(reward.get("reward_points", 0), 0, 0) + reward_xp = self._to_int(reward.get("reward_xp", 0), 0, 0) + reward_coin = self._to_int(reward.get("reward_season_coin", 0), 0, 0) + tx_payload = None + if reward_points > 0: + self._expire_point_lots_tx(conn, user_id=safe_user_id) + tx_payload = self._credit_points_tx( + conn, + safe_user_id, + reward_points, + tx_type="season_pass_reward", + source="season", + related_id=f"{safe_season_id}:{safe_reward_id}", + note=f"赛季等级奖励 {safe_reward_id}", + count_recharge=False, + create_lot=True, + allow_locked=True, + ) + granted = self._grant_reward_payload_tx( + conn, + user_id=safe_user_id, + season_id=safe_season_id, + reward_payload=reward.get("reward_payload", {}), + source="pass_reward", + ) + claimed_at = utc_now_iso() + conn.execute( + """ + INSERT INTO user_season_reward_claims(user_id, season_id, reward_id, claimed_at) + VALUES (?, ?, ?, ?) + """, + (safe_user_id, safe_season_id, safe_reward_id, claimed_at), + ) + conn.commit() + + next_profile = self.apply_user_season_gain( + safe_user_id, + safe_season_id, + xp_delta=self._to_int(reward.get("reward_xp", 0), 0, 0), + season_points_delta=self._to_int(reward.get("reward_season_coin", 0), 0, 0), + ) + return { + "reward": reward, + "profile": next_profile, + "transaction": (tx_payload or {}).get("tx", {}), + "granted_decorations": granted, + } + + def _build_season_card_pool_payload(self, row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + item = dict(row) + item["target_value"] = self._to_int(item.get("target_value", 1), 1, 1) + item["reward_points"] = self._to_int(item.get("reward_points", 0), 0, 0) + item["reward_xp"] = self._to_int(item.get("reward_xp", 0), 0, 0) + item["weight"] = self._to_int(item.get("weight", 100), 100, 1) + item["min_level"] = self._to_int(item.get("min_level", 1), 1, 1) + item["sort_order"] = self._to_int(item.get("sort_order", 0), 0, 0) + item["payload"] = self._json_object(item.get("payload_json", "{}")) + item["is_active"] = bool(item.get("is_active", 0)) + return item + + def list_season_card_pools( + self, + season_id: str, + *, + active_only: bool = True, + limit: int = 200, + ) -> list[dict[str, Any]]: + safe_season_id = str(season_id or "").strip() + if not safe_season_id: + return [] + safe_limit = max(1, min(2000, int(limit))) + where = "WHERE season_id=?" + if active_only: + where += " AND is_active=1" + with self._lock, self._connect() as conn: + rows = conn.execute( + f""" + SELECT * + FROM season_card_pools + {where} + ORDER BY sort_order ASC, weight DESC, updated_at DESC + LIMIT ? + """, + (safe_season_id, safe_limit), + ).fetchall() + return [self._build_season_card_pool_payload(row) for row in rows] + + def upsert_season_card_pool( + self, + season_id: str, + payload: dict[str, Any], + *, + pool_id: str = "", + ) -> dict[str, Any]: + safe_season_id = str(season_id or "").strip() + if not safe_season_id or self.get_season(safe_season_id) is None: + raise ValueError("invalid_card_id") + raw = payload if isinstance(payload, dict) else {} + safe_card_type = str(raw.get("card_type", "rotating")).strip().lower() + if safe_card_type not in {"daily", "rotating", "surprise", "storyline"}: + safe_card_type = "rotating" + safe_title_hint = str(raw.get("title_hint", "")).strip()[:84] + if not safe_title_hint: + raise ValueError("invalid_card_payload") + safe_story_hook = str(raw.get("story_hook", "")).strip()[:360] + safe_objective = str(raw.get("objective_template", "")).strip()[:240] + safe_hint = str(raw.get("hint_template", "")).strip()[:240] + safe_event_key = str(raw.get("event_key", "complete_game")).strip().lower()[:48] or "complete_game" + safe_target = self._to_int(raw.get("target_value", 1), 1, 1) + safe_points = self._to_int(raw.get("reward_points", 0), 0, 0) + safe_xp = self._to_int(raw.get("reward_xp", 0), 0, 0) + safe_rarity = str(raw.get("rarity", "normal")).strip().lower() + if safe_rarity not in SEASON_CARD_RARITY_MULTIPLIER: + safe_rarity = "normal" + safe_weight = self._to_int(raw.get("weight", 100), 100, 1) + safe_min_level = self._to_int(raw.get("min_level", 1), 1, 1) + safe_payload = raw.get("payload", {}) + safe_active = bool(raw.get("is_active", True)) + safe_sort = self._to_int(raw.get("sort_order", 0), 0, 0) + safe_pool_id = str(pool_id or raw.get("pool_id", "")).strip()[:80] + if not safe_pool_id: + seed = re.sub(r"[^a-z0-9]+", "_", safe_title_hint.lower()).strip("_")[:32] or "pool" + safe_pool_id = f"{safe_season_id}_{safe_card_type}_{seed}_{secrets.token_hex(3)}"[:80] + now = utc_now_iso() + with self._lock, self._connect() as conn: + exists = conn.execute("SELECT pool_id FROM season_card_pools WHERE pool_id=?", (safe_pool_id,)).fetchone() + if exists: + conn.execute( + """ + UPDATE season_card_pools + SET season_id=?, card_type=?, title_hint=?, story_hook=?, objective_template=?, hint_template=?, + event_key=?, target_value=?, reward_points=?, reward_xp=?, rarity=?, weight=?, min_level=?, + payload_json=?, is_active=?, sort_order=?, updated_at=? + WHERE pool_id=? + """, + ( + safe_season_id, + safe_card_type, + safe_title_hint, + safe_story_hook, + safe_objective, + safe_hint, + safe_event_key, + safe_target, + safe_points, + safe_xp, + safe_rarity, + safe_weight, + safe_min_level, + json.dumps(safe_payload if isinstance(safe_payload, dict) else {}, ensure_ascii=False), + 1 if safe_active else 0, + safe_sort, + now, + safe_pool_id, + ), + ) + else: + conn.execute( + """ + INSERT INTO season_card_pools( + pool_id, season_id, card_type, title_hint, story_hook, objective_template, hint_template, + event_key, target_value, reward_points, reward_xp, rarity, weight, min_level, payload_json, + is_active, sort_order, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + safe_pool_id, + safe_season_id, + safe_card_type, + safe_title_hint, + safe_story_hook, + safe_objective, + safe_hint, + safe_event_key, + safe_target, + safe_points, + safe_xp, + safe_rarity, + safe_weight, + safe_min_level, + json.dumps(safe_payload if isinstance(safe_payload, dict) else {}, ensure_ascii=False), + 1 if safe_active else 0, + safe_sort, + now, + now, + ), + ) + conn.commit() + row = conn.execute("SELECT * FROM season_card_pools WHERE pool_id=?", (safe_pool_id,)).fetchone() + if row is None: + raise ValueError("invalid_response") + return self._build_season_card_pool_payload(row) + + def _season_refresh_hours(self, season: dict[str, Any]) -> list[int]: + cfg = season.get("config", {}) + if not isinstance(cfg, dict): + cfg = {} + raw_hours = cfg.get("refresh_hours", [0, 8, 18]) + if not isinstance(raw_hours, list): + raw_hours = [0, 8, 18] + hours: list[int] = [] + for row in raw_hours[:8]: + try: + value = int(row) + except (TypeError, ValueError): + continue + value = max(0, min(23, value)) + if value not in hours: + hours.append(value) + hours.sort() + return hours or [0, 8, 18] + + def _season_refresh_slot(self, now_dt: datetime, refresh_hours: list[int]) -> tuple[str, datetime]: + hours = sorted({max(0, min(23, int(row))) for row in refresh_hours}) if refresh_hours else [0, 8, 18] + if not hours: + hours = [0, 8, 18] + day = now_dt.replace(minute=0, second=0, microsecond=0) + slot_hour = -1 + for hour in hours: + if now_dt.hour >= hour: + slot_hour = hour + if slot_hour < 0: + day = (day - timedelta(days=1)).replace(hour=0) + slot_hour = hours[-1] + slot_time = day.replace(hour=slot_hour) + refresh_key = slot_time.strftime("%Y%m%d%H") + next_slot: datetime | None = None + for hour in hours: + candidate = day.replace(hour=hour) + if candidate > slot_time: + next_slot = candidate + break + if next_slot is None: + next_slot = (day + timedelta(days=1)).replace(hour=hours[0]) + return refresh_key, next_slot + + def _seed_intel_pool(self, season: dict[str, Any], *, day_key: str) -> list[dict[str, str]]: + theme = str(season.get("theme", "")).strip()[:80] or "未知主题" + accent = str(season.get("accent_color", "#4f6dff")).strip()[:16] or "#4f6dff" + seed = str(season.get("story_seed", "")).strip()[:160] + outline = str(season.get("story_outline", "")).strip()[:220] + return [ + {"intel_key": f"{day_key}:signal", "title": "电波摘录", "text": f"今日电波与“{theme}”共振,出现了新的异常频段。"}, + {"intel_key": f"{day_key}:file", "title": "档案碎片", "text": seed or f"档案编号被抹去,只剩下与“{theme}”相关的暗示。"}, + {"intel_key": f"{day_key}:map", "title": "街区地图", "text": outline or "地图上被标记的区域正在扩大,线索开始形成路径。"}, + {"intel_key": f"{day_key}:tone", "title": "主题色注记", "text": f"记录员用 {accent} 标注关键节点,提示这是主线转折点。"}, + ] + + def get_season_daily_game_card(self, season_id: str, *, day_key: str = "") -> dict[str, Any] | None: + safe_season_id = str(season_id or "").strip() + safe_day_key = str(day_key or "").strip()[:16] or _utc_day_key() + if not safe_season_id: + return None + with self._lock, self._connect() as conn: + row = conn.execute( + """ + SELECT * + FROM season_daily_game_cards + WHERE day_key=? AND season_id=? + """, + (safe_day_key, safe_season_id), + ).fetchone() + if row is None: + return None + item = dict(row) + item["locked_intel"] = self._json_list(item.get("locked_intel_json", "[]")) + return item + + def ensure_season_daily_game_card( + self, + season_id: str, + *, + card_payload: dict[str, Any] | None = None, + source: str = "ai", + ) -> dict[str, Any]: + safe_season_id = str(season_id or "").strip() + season = self.get_season(safe_season_id) + if season is None: + raise ValueError("invalid_card_id") + day_key = _utc_day_key() + existing = self.get_season_daily_game_card(safe_season_id, day_key=day_key) + if existing is not None: + return existing + + payload = card_payload if isinstance(card_payload, dict) else {} + if payload: + card = self.create_game_card( + world_id=f"season:{safe_season_id}", + title=str(payload.get("title", "")), + subtitle=str(payload.get("subtitle", "")), + summary=str(payload.get("summary", "")), + cover_url=str(payload.get("cover_url", "")), + opening_text=str(payload.get("opening_text", "")), + starter_goals=payload.get("starter_goals", []), + state_schema=payload.get("state_schema", {}), + content_pack=payload.get("content_pack", {}), + creator_user_id="system", + ) + else: + theme = str(season.get("theme", "")).strip()[:80] or "赛季主题" + season_name = str(season.get("name", "")).strip()[:80] or theme + title = season_name + subtitle = f"{theme} · {day_key}" + summary = ( + str(season.get("description", "")).strip()[:200] + or f"{theme} 赛季关联剧本。" + ) + opening_text = ( + str(season.get("story_seed", "")).strip()[:600] + or str(season.get("story_outline", "")).strip()[:600] + or f"你踏入“{theme}”的世界,新的局势正悄然展开。" + ) + starter_goals = ["确认世界观与角色定位", "完成一次关键抉择", "推进到阶段目标"] + cover_url = str(season.get("cover_url", "")).strip() or "/static/img/cards/fogharbor.svg" + accent = str(season.get("accent_color", "#4f6dff")).strip()[:16] or "#4f6dff" + state_schema = { + "ui": { + "cover_url": cover_url, + "room_background_url": cover_url, + "hero_status": {"key": "season_state", "label": "态势", "min": 0, "max": 100, "color": accent}, + } + } + content_pack = { + "genre_tags": ["赛季关联"], + "feature_badges": ["赛季关联"], + "world_lore": str(season.get("story_seed", "")).strip()[:280], + "core_conflict": str(season.get("story_outline", "")).strip()[:280] or f"{theme} 的局势正在成形。", + "npc_roster": ["关键角色", "关键势力", "不明变量"], + "scene_blueprint": ["开场", "推进", "转折"], + "ending_catalog": ["完成阶段目标", "撤离并保留线索"], + } + card = self.create_game_card( + world_id=f"season:{safe_season_id}", + title=title, + subtitle=subtitle, + summary=summary, + cover_url=cover_url, + opening_text=opening_text, + starter_goals=starter_goals, + state_schema=state_schema, + content_pack=content_pack, + creator_user_id="system", + ) + intel_pool = self._seed_intel_pool(season, day_key=day_key) + guide_text = f"赛季引导:每天会生成 1 张与“{str(season.get('theme', '')).strip()[:80] or '赛季主题'}”契合的赛季剧本卡。" + intro_text = "今天的资料已准备就绪:通关后可解锁其中一条。" + now = utc_now_iso() + with self._lock, self._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + conn.execute( + """ + INSERT INTO season_daily_game_cards( + day_key, season_id, card_id, title, summary, guide_text, intro_text, locked_intel_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + day_key, + safe_season_id, + str(card.get("card_id", "")).strip(), + title, + summary, + guide_text, + intro_text, + json.dumps(intel_pool, ensure_ascii=False), + now, + now, + ), + ) + conn.commit() + row2 = conn.execute( + "SELECT * FROM season_daily_game_cards WHERE day_key=? AND season_id=?", + (day_key, safe_season_id), + ).fetchone() + if row2 is None: + raise ValueError("invalid_response") + item2 = dict(row2) + item2["locked_intel"] = self._json_list(item2.get("locked_intel_json", "[]")) + return item2 + + def unlock_season_intel(self, user_id: str, season_id: str, *, day_key: str, intel_key: str = "") -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + safe_day_key = str(day_key or "").strip()[:16] + if not safe_user_id or not safe_season_id or not safe_day_key: + raise ValueError("invalid_card_payload") + if self.get_user_by_id(safe_user_id) is None: + raise ValueError("user_not_found") + season = self.get_season(safe_season_id) + if season is None: + raise ValueError("invalid_card_id") + if not self.can_unlock_season_intel(safe_user_id, safe_season_id, day_key=safe_day_key): + raise ValueError("season_daily_card_not_completed") + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT locked_intel_json FROM season_daily_game_cards WHERE day_key=? AND season_id=?", + (safe_day_key, safe_season_id), + ).fetchone() + if row is None: + raise ValueError("invalid_card_id") + pool = self._json_list(dict(row).get("locked_intel_json", "[]")) + candidates = [item for item in pool if isinstance(item, dict) and str(item.get("intel_key", "")).strip()] + if not candidates: + raise ValueError("invalid_card_payload") + chosen = candidates[0] + if intel_key: + override = next((c for c in candidates if str(c.get("intel_key", "")) == str(intel_key)), None) + if override is not None: + chosen = override + safe_intel_key = str(chosen.get("intel_key", "")).strip()[:80] + safe_title = str(chosen.get("title", "")).strip()[:64] + safe_text = str(chosen.get("text", "")).strip()[:900] + now = utc_now_iso() + with self._lock, self._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + conn.execute( + """ + INSERT INTO user_season_intel_unlocks( + user_id, season_id, day_key, intel_key, intel_title, intel_text, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, season_id, day_key, intel_key) DO NOTHING + """, + (safe_user_id, safe_season_id, safe_day_key, safe_intel_key, safe_title, safe_text, now), + ) + conn.commit() + return { + "day_key": safe_day_key, + "intel_key": safe_intel_key, + "intel_title": safe_title, + "intel_text": safe_text, + } + + def mark_season_daily_card_completed( + self, + user_id: str, + season_id: str, + *, + day_key: str, + room_id: str, + ending_state: str = "", + ending_summary: str = "", + ) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + safe_day_key = str(day_key or "").strip()[:16] + safe_room_id = str(room_id or "").strip().upper()[:32] + if not safe_user_id or not safe_season_id or not safe_day_key or not safe_room_id: + raise ValueError("invalid_card_payload") + + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT card_id FROM season_daily_game_cards WHERE season_id=? AND day_key=?", + (safe_season_id, safe_day_key), + ).fetchone() + if row is None: + raise ValueError("invalid_card_id") + safe_card_id = str(dict(row).get("card_id", "")).strip()[:64] + if not safe_card_id: + raise ValueError("invalid_card_id") + + safe_ending_state = str(ending_state or "").strip().lower()[:32] + safe_ending_summary = str(ending_summary or "").strip()[:1600] + now = utc_now_iso() + with self._lock, self._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + conn.execute( + """ + INSERT INTO season_daily_card_completions( + user_id, season_id, day_key, room_id, card_id, ending_state, ending_summary, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, season_id, day_key) DO NOTHING + """, + ( + safe_user_id, + safe_season_id, + safe_day_key, + safe_room_id, + safe_card_id, + safe_ending_state, + safe_ending_summary, + now, + ), + ) + conn.commit() + + return { + "user_id": safe_user_id, + "season_id": safe_season_id, + "day_key": safe_day_key, + "room_id": safe_room_id, + "card_id": safe_card_id, + "created_at": now, + } + + def can_unlock_season_intel(self, user_id: str, season_id: str, *, day_key: str) -> bool: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + safe_day_key = str(day_key or "").strip()[:16] + if not safe_user_id or not safe_season_id or not safe_day_key: + return False + with self._lock, self._connect() as conn: + row = conn.execute( + """ + SELECT id + FROM season_daily_card_completions + WHERE user_id=? AND season_id=? AND day_key=? + LIMIT 1 + """, + (safe_user_id, safe_season_id, safe_day_key), + ).fetchone() + return row is not None + + def list_user_season_intel(self, user_id: str, season_id: str, *, limit: int = 200) -> list[dict[str, Any]]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + safe_limit = max(1, min(2000, int(limit))) + if not safe_user_id or not safe_season_id: + return [] + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT day_key, intel_key, intel_title, intel_text, created_at + FROM user_season_intel_unlocks + WHERE user_id=? AND season_id=? + ORDER BY created_at ASC, id ASC + LIMIT ? + """, + (safe_user_id, safe_season_id, safe_limit), + ).fetchall() + return [dict(r) for r in rows] + + def build_season_chronicle_items( + self, + user_id: str, + season_id: str, + *, + limit_days: int = 60, + ) -> list[dict[str, Any]]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + safe_limit = max(1, min(365, int(limit_days))) + if not safe_user_id or not safe_season_id: + raise ValueError("invalid_card_payload") + if self.get_user_by_id(safe_user_id) is None: + raise ValueError("user_not_found") + if self.get_season(safe_season_id) is None: + raise ValueError("season_not_found") + today_key = _utc_day_key() + + with self._lock, self._connect() as conn: + daily_rows = conn.execute( + """ + SELECT day_key, title, summary, guide_text, intro_text, created_at + FROM season_daily_game_cards + WHERE season_id=? + ORDER BY day_key DESC + LIMIT ? + """, + (safe_season_id, safe_limit), + ).fetchall() + + intel_rows = conn.execute( + """ + SELECT day_key, intel_title, intel_text, created_at + FROM user_season_intel_unlocks + WHERE user_id=? AND season_id=? + ORDER BY created_at ASC, id ASC + LIMIT 2000 + """, + (safe_user_id, safe_season_id), + ).fetchall() + + intel_by_day: dict[str, list[dict[str, Any]]] = {} + for row in intel_rows: + item = dict(row) + day_key = str(item.get("day_key", "")).strip() + if not day_key: + continue + intel_by_day.setdefault(day_key, []).append( + { + "intel_title": str(item.get("intel_title", "")).strip()[:64], + "intel_text": str(item.get("intel_text", "")).strip()[:900], + "created_at": str(item.get("created_at", "")).strip(), + } + ) + + items: list[dict[str, Any]] = [] + for row in daily_rows: + item = dict(row) + day_key = str(item.get("day_key", "")).strip() + items.append( + { + "day_key": day_key, + "title": str(item.get("title", "")).strip()[:120], + "summary": str(item.get("summary", "")).strip()[:900], + "guide_text": str(item.get("guide_text", "")).strip()[:1800], + "intro_text": str(item.get("intro_text", "")).strip()[:1800], + "created_at": str(item.get("created_at", "")).strip(), + "is_today": bool(day_key == today_key), + "intel": intel_by_day.get(day_key, []), + } + ) + + return items + + def _get_story_thread(self, user_id: str, season_id: str) -> dict[str, Any]: + with self._lock, self._connect() as conn: + row = conn.execute( + """ + SELECT id, user_id, season_id, thread_summary, thread_state_json, last_generated_at, updated_at + FROM season_story_threads + WHERE user_id=? AND season_id=? + """, + (user_id, season_id), + ).fetchone() + if row is None: + return { + "id": 0, + "user_id": user_id, + "season_id": season_id, + "thread_summary": "", + "thread_state": {"chapter": 0, "last_event": "", "history": []}, + "last_generated_at": "", + "updated_at": "", + } + item = dict(row) + state = self._json_object(item.get("thread_state_json", "{}")) + if not isinstance(state.get("history"), list): + state["history"] = [] + state["chapter"] = self._to_int(state.get("chapter", 0), 0, 0) + state["last_event"] = str(state.get("last_event", "")).strip()[:240] + return { + "id": self._to_int(item.get("id", 0), 0, 0), + "user_id": str(item.get("user_id", "")).strip(), + "season_id": str(item.get("season_id", "")).strip(), + "thread_summary": str(item.get("thread_summary", "")).strip()[:2400], + "thread_state": state, + "last_generated_at": str(item.get("last_generated_at", "")).strip(), + "updated_at": str(item.get("updated_at", "")).strip(), + } + + def _save_story_thread(self, user_id: str, season_id: str, thread_state: dict[str, Any], *, summary: str = "") -> None: + now = utc_now_iso() + safe_state = thread_state if isinstance(thread_state, dict) else {} + with self._lock, self._connect() as conn: + conn.execute( + """ + INSERT INTO season_story_threads( + user_id, season_id, thread_summary, thread_state_json, last_generated_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, season_id) DO UPDATE SET + thread_summary=excluded.thread_summary, + thread_state_json=excluded.thread_state_json, + last_generated_at=excluded.last_generated_at, + updated_at=excluded.updated_at + """, + ( + str(user_id or "").strip(), + str(season_id or "").strip(), + str(summary or "").strip()[:2400], + json.dumps(safe_state, ensure_ascii=False), + now, + now, + ), + ) + conn.commit() + + def _pick_pool_rows(self, pools: list[dict[str, Any]], count: int) -> list[dict[str, Any]]: + if count <= 0: + return [] + daily = [row for row in pools if str(row.get("card_type", "")) == "daily"] + rotating = [row for row in pools if str(row.get("card_type", "")) == "rotating"] + surprise = [row for row in pools if str(row.get("card_type", "")) == "surprise"] + others = [row for row in pools if row not in daily and row not in rotating and row not in surprise] + selected: list[dict[str, Any]] = [] + if daily: + selected.extend(self._weighted_sample_rows(daily, 1)) + if len(selected) < count and rotating: + selected.extend(self._weighted_sample_rows(rotating, 1)) + if len(selected) < count and surprise and secrets.randbelow(100) < 12: + selected.extend(self._weighted_sample_rows(surprise, 1)) + remaining = [row for row in pools if row not in selected] + if len(selected) < count and remaining: + selected.extend(self._weighted_sample_rows(remaining, count - len(selected))) + if len(selected) < count: + selected.extend(self._weighted_sample_rows(others, count - len(selected))) + return selected[:count] + + def _compose_season_card_payload( + self, + season: dict[str, Any], + thread_state: dict[str, Any], + pool: dict[str, Any], + *, + index: int, + ) -> dict[str, Any]: + chapter = self._to_int(thread_state.get("chapter", 0), 0, 0) + 1 + history = thread_state.get("history", []) + if not isinstance(history, list): + history = [] + last_event = str(thread_state.get("last_event", "")).strip() + title_hint = str(pool.get("title_hint", "异象速递")).strip()[:64] or "异象速递" + opener = SEASON_STORY_OPENERS[(chapter + index) % len(SEASON_STORY_OPENERS)] + twist = SEASON_STORY_TWISTS[(chapter + index) % len(SEASON_STORY_TWISTS)] + hook = str(pool.get("story_hook", "")).strip()[:280] + theme = str(season.get("theme", "赛季主题")).strip()[:80] or "赛季主题" + if last_event: + continuation = f"延续昨日线索:{last_event[:70]}。" + else: + continuation = "故事刚刚开始,所有线索都处于散落状态。" + story_text = f"{opener} {continuation} {hook or twist} 主题“{theme}”的主线继续推进。" + objective_template = str(pool.get("objective_template", "")).strip()[:220] + objective = objective_template or f"完成 {self._to_int(pool.get('target_value', 1), 1, 1)} 次关键行动。" + hint_template = str(pool.get("hint_template", "")).strip()[:220] + hint = hint_template or SEASON_HINT_POOL[(chapter + index) % len(SEASON_HINT_POOL)] + rarity = str(pool.get("rarity", "normal")).strip().lower() + if rarity not in SEASON_CARD_RARITY_MULTIPLIER: + rarity = "normal" + ratio = float(SEASON_CARD_RARITY_MULTIPLIER.get(rarity, 1.0)) + reward_points = int(round(self._to_int(pool.get("reward_points", 0), 0, 0) * ratio)) + reward_xp = int(round(self._to_int(pool.get("reward_xp", 0), 0, 0) * ratio)) + return { + "title": f"{title_hint} · 第{chapter}幕", + "story_text": story_text[:420], + "objective": objective[:220], + "hint_text": hint[:220], + "event_key": str(pool.get("event_key", "complete_game")).strip().lower()[:48] or "complete_game", + "target_value": self._to_int(pool.get("target_value", 1), 1, 1), + "reward_points": max(0, reward_points), + "reward_xp": max(0, reward_xp), + "rarity": rarity, + "card_type": str(pool.get("card_type", "rotating")).strip().lower() or "rotating", + "source_pool_id": str(pool.get("pool_id", "")).strip()[:80], + "payload": { + "theme": theme, + "chapter": chapter, + "continuity_prev": last_event[:120], + "history_tail": history[-5:], + "pool_payload": pool.get("payload", {}), + }, + "next_event": f"{title_hint} 触发了新的异象回响", + } + + def _build_user_season_card_payload(self, row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + item = dict(row) + item["reward_points"] = self._to_int(item.get("reward_points", 0), 0, 0) + item["reward_xp"] = self._to_int(item.get("reward_xp", 0), 0, 0) + item["target_value"] = self._to_int(item.get("target_value", 1), 1, 1) + item["progress_value"] = self._to_int(item.get("progress_value", 0), 0, 0) + item["payload"] = self._json_object(item.get("payload_json", "{}")) + target = max(1, item["target_value"]) + item["progress_percent"] = min(100, int(round((min(item["progress_value"], target) / target) * 100))) + return item + + def ensure_user_season_cards(self, user_id: str, season_id: str) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + season = self.get_season(safe_season_id) + if not safe_user_id or season is None: + raise ValueError("invalid_card_id") + profile = self.get_or_create_user_season_profile(safe_user_id, safe_season_id) + season_cfg = season.get("config", {}) + if not isinstance(season_cfg, dict): + season_cfg = {} + now_dt = self._parse_iso_datetime(utc_now_iso()) or datetime.now(timezone.utc) + refresh_hours = self._season_refresh_hours(season) + refresh_key, next_refresh_at = self._season_refresh_slot(now_dt, refresh_hours) + card_count = self._to_int(season_cfg.get("daily_card_count", 3), 3, 1) + with self._lock, self._connect() as conn: + existing = conn.execute( + """ + SELECT * + FROM season_activity_cards + WHERE user_id=? AND season_id=? AND refresh_key=? AND status IN ('active', 'completed') + ORDER BY id ASC + """, + (safe_user_id, safe_season_id, refresh_key), + ).fetchall() + if existing: + cards = [self._build_user_season_card_payload(row) for row in existing] + return { + "refresh_key": refresh_key, + "next_refresh_at": next_refresh_at.isoformat(), + "cards": cards, + "generated": False, + } + conn.execute("BEGIN IMMEDIATE") + conn.execute( + """ + UPDATE season_activity_cards + SET status='expired', updated_at=? + WHERE user_id=? AND season_id=? AND status IN ('active', 'completed') AND refresh_key<>? + """, + (utc_now_iso(), safe_user_id, safe_season_id, refresh_key), + ) + pools = self.list_season_card_pools(safe_season_id, active_only=True, limit=600) + level = self._to_int(profile.get("level", 1), 1, 1) + pools = [row for row in pools if self._to_int(row.get("min_level", 1), 1, 1) <= level] + if not pools: + pools = [ + { + "pool_id": f"{safe_season_id}_fallback", + "card_type": "daily", + "title_hint": "每日简报", + "story_hook": "系统未配置卡池,已启用兜底任务。", + "objective_template": "完成 1 局游戏。", + "hint_template": "先完成基础对局领取奖励。", + "event_key": "complete_game", + "target_value": 1, + "reward_points": 12, + "reward_xp": 28, + "rarity": "normal", + "payload": {}, + "weight": 100, + } + ] + picked = self._pick_pool_rows(pools, card_count) + thread = self._get_story_thread(safe_user_id, safe_season_id) + thread_state = thread.get("thread_state", {}) + if not isinstance(thread_state, dict): + thread_state = {"chapter": 0, "last_event": "", "history": []} + history = thread_state.get("history", []) + if not isinstance(history, list): + history = [] + inserted: list[dict[str, Any]] = [] + for idx, pool in enumerate(picked): + card_payload = self._compose_season_card_payload( + season, + thread_state, + pool, + index=idx, + ) + now_iso = utc_now_iso() + conn.execute( + """ + INSERT INTO season_activity_cards( + user_id, season_id, refresh_key, title, story_text, objective, hint_text, + reward_points, reward_xp, rarity, status, claimed_at, expires_at, payload_json, + created_at, updated_at, claimed_tx_id, event_key, target_value, progress_value, card_type, source_pool_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', '', ?, ?, ?, ?, '', ?, ?, 0, ?, ?) + """, + ( + safe_user_id, + safe_season_id, + refresh_key, + card_payload["title"], + card_payload["story_text"], + card_payload["objective"], + card_payload["hint_text"], + card_payload["reward_points"], + card_payload["reward_xp"], + card_payload["rarity"], + next_refresh_at.isoformat(), + json.dumps(card_payload["payload"], ensure_ascii=False), + now_iso, + now_iso, + card_payload["event_key"], + card_payload["target_value"], + card_payload["card_type"], + card_payload["source_pool_id"], + ), + ) + row_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + created = conn.execute("SELECT * FROM season_activity_cards WHERE id=?", (row_id,)).fetchone() + if created: + inserted.append(self._build_user_season_card_payload(created)) + thread_state["chapter"] = self._to_int(thread_state.get("chapter", 0), 0, 0) + 1 + thread_state["last_event"] = card_payload.get("next_event", "") + history.append(card_payload["title"]) + thread_state["history"] = history[-30:] + conn.execute( + """ + UPDATE user_season_profiles + SET last_refresh_key=?, last_generated_at=?, updated_at=? + WHERE user_id=? AND season_id=? + """, + (refresh_key, utc_now_iso(), utc_now_iso(), safe_user_id, safe_season_id), + ) + conn.commit() + self._save_story_thread( + safe_user_id, + safe_season_id, + thread_state, + summary=f"已推进到第 {self._to_int(thread_state.get('chapter', 0), 0, 0)} 幕。", + ) + return { + "refresh_key": refresh_key, + "next_refresh_at": next_refresh_at.isoformat(), + "cards": inserted, + "generated": True, + } + + def list_user_season_cards( + self, + user_id: str, + season_id: str, + *, + refresh_key: str = "", + include_expired: bool = False, + limit: int = 200, + ) -> list[dict[str, Any]]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + if not safe_user_id or not safe_season_id: + return [] + safe_refresh = str(refresh_key or "").strip() + safe_limit = max(1, min(1200, int(limit))) + where = ["user_id=?", "season_id=?"] + params: list[Any] = [safe_user_id, safe_season_id] + if safe_refresh: + where.append("refresh_key=?") + params.append(safe_refresh) + if not include_expired: + where.append("status<>'expired'") + where_sql = " AND ".join(where) + with self._lock, self._connect() as conn: + rows = conn.execute( + f""" + SELECT * + FROM season_activity_cards + WHERE {where_sql} + ORDER BY id DESC + LIMIT ? + """, + tuple(params + [safe_limit]), + ).fetchall() + return [self._build_user_season_card_payload(row) for row in rows] + + def progress_user_season_cards( + self, + user_id: str, + season_id: str, + *, + event_key: str, + amount: int = 1, + ) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + safe_event = str(event_key or "").strip().lower() + safe_amount = self._to_int(amount, 1, 1) + if not safe_user_id or not safe_season_id or not safe_event: + return {"updated": [], "completed_ids": []} + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT * + FROM season_activity_cards + WHERE user_id=? AND season_id=? AND event_key=? AND status='active' + ORDER BY id ASC + """, + (safe_user_id, safe_season_id, safe_event), + ).fetchall() + updated: list[dict[str, Any]] = [] + completed_ids: list[int] = [] + now = utc_now_iso() + for row in rows: + item = self._build_user_season_card_payload(row) + target = self._to_int(item.get("target_value", 1), 1, 1) + before = self._to_int(item.get("progress_value", 0), 0, 0) + after = min(target, before + safe_amount) + next_status = "completed" if after >= target else "active" + conn.execute( + """ + UPDATE season_activity_cards + SET progress_value=?, status=?, updated_at=? + WHERE id=? + """, + (after, next_status, now, int(item.get("id", 0))), + ) + item["progress_value"] = after + item["status"] = next_status + item["progress_percent"] = min(100, int(round((after / target) * 100))) if target > 0 else 0 + if next_status == "completed": + completed_ids.append(int(item.get("id", 0))) + updated.append(item) + conn.commit() + return {"updated": updated, "completed_ids": completed_ids} + + def claim_user_season_card(self, user_id: str, season_id: str, card_id: int) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + safe_card_id = self._to_int(card_id, 0, 1) + if not safe_user_id or not safe_season_id or safe_card_id <= 0: + raise ValueError("invalid_card_payload") + with self._lock, self._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + """ + SELECT * + FROM season_activity_cards + WHERE id=? AND user_id=? AND season_id=? + """, + (safe_card_id, safe_user_id, safe_season_id), + ).fetchone() + if row is None: + raise ValueError("invalid_card_id") + card = self._build_user_season_card_payload(row) + status = str(card.get("status", "active")).strip().lower() + if status == "claimed": + raise ValueError("shop_item_limit_reached") + if status != "completed": + raise ValueError("invalid_card_payload") + reward_points = self._to_int(card.get("reward_points", 0), 0, 0) + reward_xp = self._to_int(card.get("reward_xp", 0), 0, 0) + tx_payload = None + if reward_points > 0: + self._expire_point_lots_tx(conn, user_id=safe_user_id) + tx_payload = self._credit_points_tx( + conn, + safe_user_id, + reward_points, + tx_type="season_card_reward", + source="season", + related_id=f"{safe_season_id}:{safe_card_id}", + note=f"赛季卡片奖励 #{safe_card_id}", + count_recharge=False, + create_lot=True, + allow_locked=True, + ) + granted = self._grant_reward_payload_tx( + conn, + user_id=safe_user_id, + season_id=safe_season_id, + reward_payload=card.get("payload", {}), + source="season_card", + ) + claimed_at = utc_now_iso() + conn.execute( + """ + UPDATE season_activity_cards + SET status='claimed', claimed_at=?, updated_at=? + WHERE id=? + """, + (claimed_at, claimed_at, safe_card_id), + ) + conn.commit() + next_profile = self.apply_user_season_gain( + safe_user_id, + safe_season_id, + xp_delta=reward_xp, + add_claimed_card=True, + ) + cards = self.list_user_season_cards(safe_user_id, safe_season_id, include_expired=False, limit=200) + claimed_card = next((row for row in cards if int(row.get("id", 0)) == safe_card_id), {}) + return { + "card": claimed_card, + "profile": next_profile, + "transaction": (tx_payload or {}).get("tx", {}), + "granted_decorations": granted, + } + + def refresh_user_season_card(self, user_id: str, season_id: str, card_id: int, *, refresh_cost: int = 60) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_season_id = str(season_id or "").strip() + safe_card_id = self._to_int(card_id, 0, 1) + if not safe_user_id or not safe_season_id or safe_card_id <= 0: + raise ValueError("invalid_card_payload") + season = self.get_season(safe_season_id) + if season is None: + raise ValueError("invalid_card_id") + safe_cost = self._to_int(refresh_cost, 60, 0) + profile = self.get_or_create_user_season_profile(safe_user_id, safe_season_id) + pools = self.list_season_card_pools(safe_season_id, active_only=True, limit=800) + level = self._to_int(profile.get("level", 1), 1, 1) + pools = [row for row in pools if self._to_int(row.get("min_level", 1), 1, 1) <= level] + if not pools: + raise ValueError("invalid_card_payload") + with self._lock, self._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + """ + SELECT * + FROM season_activity_cards + WHERE id=? AND user_id=? AND season_id=? + """, + (safe_card_id, safe_user_id, safe_season_id), + ).fetchone() + if row is None: + raise ValueError("invalid_card_id") + old_card = self._build_user_season_card_payload(row) + if str(old_card.get("status", "")).strip().lower() not in {"active", "completed"}: + raise ValueError("invalid_card_payload") + self._expire_point_lots_tx(conn, user_id=safe_user_id) + debit_payload = self._debit_points_tx( + conn, + safe_user_id, + safe_cost, + tx_type="season_card_refresh", + source="season", + related_id=f"{safe_season_id}:{safe_card_id}", + note=f"刷新赛季卡片 #{safe_card_id}", + count_spent=True, + ) + refresh_key = str(old_card.get("refresh_key", "")).strip() + expires_at = str(old_card.get("expires_at", "")).strip() + pool = self._weighted_sample_rows(pools, 1)[0] + thread = self._get_story_thread(safe_user_id, safe_season_id) + thread_state = thread.get("thread_state", {}) + if not isinstance(thread_state, dict): + thread_state = {"chapter": 0, "last_event": "", "history": []} + new_payload = self._compose_season_card_payload(season, thread_state, pool, index=0) + now_iso = utc_now_iso() + conn.execute( + """ + UPDATE season_activity_cards + SET status='expired', updated_at=? + WHERE id=? + """, + (now_iso, safe_card_id), + ) + conn.execute( + """ + INSERT INTO season_activity_cards( + user_id, season_id, refresh_key, title, story_text, objective, hint_text, + reward_points, reward_xp, rarity, status, claimed_at, expires_at, payload_json, created_at, updated_at, + claimed_tx_id, event_key, target_value, progress_value, card_type, source_pool_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', '', ?, ?, ?, ?, '', ?, ?, 0, ?, ?) + """, + ( + safe_user_id, + safe_season_id, + refresh_key, + new_payload["title"], + new_payload["story_text"], + new_payload["objective"], + new_payload["hint_text"], + new_payload["reward_points"], + new_payload["reward_xp"], + new_payload["rarity"], + expires_at, + json.dumps(new_payload["payload"], ensure_ascii=False), + now_iso, + now_iso, + new_payload["event_key"], + new_payload["target_value"], + new_payload["card_type"], + new_payload["source_pool_id"], + ), + ) + new_card_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + row_new = conn.execute("SELECT * FROM season_activity_cards WHERE id=?", (new_card_id,)).fetchone() + conn.commit() + thread_state["chapter"] = self._to_int(thread_state.get("chapter", 0), 0, 0) + 1 + history = thread_state.get("history", []) + if not isinstance(history, list): + history = [] + history.append(new_payload["title"]) + thread_state["history"] = history[-30:] + thread_state["last_event"] = new_payload.get("next_event", "") + self._save_story_thread( + safe_user_id, + safe_season_id, + thread_state, + summary=f"手动刷新后推进到第 {self._to_int(thread_state.get('chapter', 0), 0, 0)} 幕。", + ) + return { + "new_card": self._build_user_season_card_payload(row_new) if row_new else {}, + "debit_transaction": debit_payload.get("tx", {}), + } + + def list_season_titles(self, season_id: str, *, active_only: bool = False, limit: int = 200) -> list[dict[str, Any]]: + safe_season_id = str(season_id or "").strip() + if not safe_season_id: + return [] + safe_limit = max(1, min(1000, int(limit))) + where = "WHERE season_id=?" + if active_only: + where += " AND is_active=1" + with self._lock, self._connect() as conn: + rows = conn.execute( + f""" + SELECT * + FROM season_titles + {where} + ORDER BY sort_order ASC, created_at ASC + LIMIT ? + """, + (safe_season_id, safe_limit), + ).fetchall() + out: list[dict[str, Any]] = [] + for row in rows: + item = dict(row) + item["is_active"] = bool(item.get("is_active", 0)) + item["effect"] = self._json_object(item.get("effect_json", "{}")) + out.append(item) + return out + + def upsert_season_title( + self, + season_id: str, + payload: dict[str, Any], + *, + title_id: str = "", + ) -> dict[str, Any]: + safe_season_id = str(season_id or "").strip() + if not safe_season_id or self.get_season(safe_season_id) is None: + raise ValueError("invalid_card_id") + raw = payload if isinstance(payload, dict) else {} + safe_name = str(raw.get("name", "")).strip()[:64] + if not safe_name: + raise ValueError("invalid_card_payload") + safe_id = str(title_id or raw.get("title_id", "")).strip()[:80] + if not safe_id: + seed = re.sub(r"[^a-z0-9]+", "_", safe_name.lower()).strip("_")[:32] or "title" + safe_id = f"{safe_season_id}_{seed}_{secrets.token_hex(2)}"[:80] + safe_color = str(raw.get("color_code", "#ffffff")).strip()[:16] or "#ffffff" + safe_rarity = str(raw.get("rarity", "normal")).strip().lower()[:24] or "normal" + safe_acquire = str(raw.get("acquire_type", "season_reward")).strip().lower()[:32] or "season_reward" + safe_image = str(raw.get("image_url", "")).strip()[:720] + safe_effect = raw.get("effect", {}) + safe_active = bool(raw.get("is_active", True)) + safe_sort = self._to_int(raw.get("sort_order", 0), 0, 0) + now = utc_now_iso() + with self._lock, self._connect() as conn: + exists = conn.execute("SELECT title_id FROM season_titles WHERE title_id=?", (safe_id,)).fetchone() + if exists: + conn.execute( + """ + UPDATE season_titles + SET season_id=?, name=?, color_code=?, rarity=?, acquire_type=?, image_url=?, effect_json=?, + is_active=?, sort_order=?, updated_at=? + WHERE title_id=? + """, + ( + safe_season_id, + safe_name, + safe_color, + safe_rarity, + safe_acquire, + safe_image, + json.dumps(safe_effect if isinstance(safe_effect, dict) else {}, ensure_ascii=False), + 1 if safe_active else 0, + safe_sort, + now, + safe_id, + ), + ) + else: + conn.execute( + """ + INSERT INTO season_titles( + title_id, season_id, name, color_code, rarity, acquire_type, image_url, effect_json, + is_active, sort_order, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + safe_id, + safe_season_id, + safe_name, + safe_color, + safe_rarity, + safe_acquire, + safe_image, + json.dumps(safe_effect if isinstance(safe_effect, dict) else {}, ensure_ascii=False), + 1 if safe_active else 0, + safe_sort, + now, + now, + ), + ) + conn.commit() + rows = self.list_season_titles(safe_season_id, active_only=False, limit=2000) + return next((row for row in rows if str(row.get("title_id", "")) == safe_id), {}) + + def list_season_avatar_frames(self, season_id: str, *, active_only: bool = False, limit: int = 200) -> list[dict[str, Any]]: + safe_season_id = str(season_id or "").strip() + if not safe_season_id: + return [] + safe_limit = max(1, min(1000, int(limit))) + where = "WHERE season_id=?" + if active_only: + where += " AND is_active=1" + with self._lock, self._connect() as conn: + rows = conn.execute( + f""" + SELECT * + FROM season_avatar_frames + {where} + ORDER BY sort_order ASC, created_at ASC + LIMIT ? + """, + (safe_season_id, safe_limit), + ).fetchall() + out: list[dict[str, Any]] = [] + for row in rows: + item = dict(row) + item["is_active"] = bool(item.get("is_active", 0)) + item["style"] = self._json_object(item.get("style_json", "{}")) + out.append(item) + return out + + def upsert_season_avatar_frame( + self, + season_id: str, + payload: dict[str, Any], + *, + frame_id: str = "", + ) -> dict[str, Any]: + safe_season_id = str(season_id or "").strip() + if not safe_season_id or self.get_season(safe_season_id) is None: + raise ValueError("invalid_card_id") + raw = payload if isinstance(payload, dict) else {} + safe_name = str(raw.get("name", "")).strip()[:64] + if not safe_name: + raise ValueError("invalid_card_payload") + safe_id = str(frame_id or raw.get("frame_id", "")).strip()[:80] + if not safe_id: + seed = re.sub(r"[^a-z0-9]+", "_", safe_name.lower()).strip("_")[:32] or "frame" + safe_id = f"{safe_season_id}_{seed}_{secrets.token_hex(2)}"[:80] + safe_style = raw.get("style", {}) + safe_rarity = str(raw.get("rarity", "normal")).strip().lower()[:24] or "normal" + safe_acquire = str(raw.get("acquire_type", "season_reward")).strip().lower()[:32] or "season_reward" + safe_preview = str(raw.get("preview_url", "")).strip()[:720] + safe_active = bool(raw.get("is_active", True)) + safe_sort = self._to_int(raw.get("sort_order", 0), 0, 0) + now = utc_now_iso() + with self._lock, self._connect() as conn: + exists = conn.execute("SELECT frame_id FROM season_avatar_frames WHERE frame_id=?", (safe_id,)).fetchone() + if exists: + conn.execute( + """ + UPDATE season_avatar_frames + SET season_id=?, name=?, style_json=?, rarity=?, acquire_type=?, preview_url=?, is_active=?, + sort_order=?, updated_at=? + WHERE frame_id=? + """, + ( + safe_season_id, + safe_name, + json.dumps(safe_style if isinstance(safe_style, dict) else {}, ensure_ascii=False), + safe_rarity, + safe_acquire, + safe_preview, + 1 if safe_active else 0, + safe_sort, + now, + safe_id, + ), + ) + else: + conn.execute( + """ + INSERT INTO season_avatar_frames( + frame_id, season_id, name, style_json, rarity, acquire_type, preview_url, + is_active, sort_order, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + safe_id, + safe_season_id, + safe_name, + json.dumps(safe_style if isinstance(safe_style, dict) else {}, ensure_ascii=False), + safe_rarity, + safe_acquire, + safe_preview, + 1 if safe_active else 0, + safe_sort, + now, + now, + ), + ) + conn.commit() + rows = self.list_season_avatar_frames(safe_season_id, active_only=False, limit=2000) + return next((row for row in rows if str(row.get("frame_id", "")) == safe_id), {}) + + def list_user_decorations(self, user_id: str, *, decoration_type: str = "", limit: int = 600) -> list[dict[str, Any]]: + safe_user_id = str(user_id or "").strip() + safe_type = str(decoration_type or "").strip().lower() + safe_limit = max(1, min(3000, int(limit))) + if not safe_user_id: + return [] + where = ["d.user_id=?"] + params: list[Any] = [safe_user_id] + if safe_type: + where.append("d.decoration_type=?") + params.append(safe_type) + where_sql = " AND ".join(where) + with self._lock, self._connect() as conn: + rows = conn.execute( + f""" + SELECT d.* + FROM user_decorations d + WHERE {where_sql} + ORDER BY d.is_equipped DESC, d.created_at DESC, d.id DESC + LIMIT ? + """, + tuple(params + [safe_limit]), + ).fetchall() + out: list[dict[str, Any]] = [] + for row in rows: + item = dict(row) + item["is_equipped"] = bool(item.get("is_equipped", 0)) + item["payload"] = self._json_object(item.get("payload_json", "{}")) + out.append(item) + return out + + def equip_user_decoration(self, user_id: str, decoration_type: str, decoration_id: str) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + safe_type = str(decoration_type or "").strip().lower() + safe_id = str(decoration_id or "").strip() + if safe_type not in {"title", "avatar_frame"} or not safe_user_id: + raise ValueError("invalid_card_payload") + with self._lock, self._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + """ + SELECT id + FROM user_decorations + WHERE user_id=? AND decoration_type=? AND decoration_id=? + """, + (safe_user_id, safe_type, safe_id), + ).fetchone() + if row is None: + raise ValueError("invalid_card_id") + now = utc_now_iso() + conn.execute( + """ + UPDATE user_decorations + SET is_equipped=0, updated_at=? + WHERE user_id=? AND decoration_type=? + """, + (now, safe_user_id, safe_type), + ) + conn.execute( + """ + UPDATE user_decorations + SET is_equipped=1, updated_at=? + WHERE user_id=? AND decoration_type=? AND decoration_id=? + """, + (now, safe_user_id, safe_type, safe_id), + ) + conn.commit() + rows = self.list_user_decorations(safe_user_id, decoration_type=safe_type, limit=200) + return next((row for row in rows if str(row.get("decoration_id", "")) == safe_id), {}) + + def get_user_equipped_decorations(self, user_id: str) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + if not safe_user_id: + return {"title": None, "avatar_frame": None} + rows = self.list_user_decorations(safe_user_id, limit=400) + title = next((row for row in rows if row.get("decoration_type") == "title" and row.get("is_equipped")), None) + frame = next((row for row in rows if row.get("decoration_type") == "avatar_frame" and row.get("is_equipped")), None) + return {"title": title, "avatar_frame": frame} + + def apply_room_summary_season_progress(self, user_id: str, room_summary: dict[str, Any]) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + summary = room_summary if isinstance(room_summary, dict) else {} + room_id = str(summary.get("room_id", "")).strip().upper() + status = str(summary.get("status", "")).strip().lower() + if not safe_user_id or not room_id or status != "ended": + return {"applied": False, "events": [], "task_updates": [], "card_updates": [], "season_id": ""} + season = self.get_active_season() + if season is None: + return {"applied": False, "events": [], "task_updates": [], "card_updates": [], "season_id": ""} + season_id = str(season.get("season_id", "")).strip() + if not season_id: + return {"applied": False, "events": [], "task_updates": [], "card_updates": [], "season_id": ""} + + day_key = "" + card_id = str(summary.get("game_card_id") or summary.get("card_id") or "").strip() + if card_id: + with self._lock, self._connect() as conn: + daily_row = conn.execute( + "SELECT day_key FROM season_daily_game_cards WHERE season_id=? AND card_id=?", + (season_id, card_id), + ).fetchone() + if daily_row is not None: + day_key = str(dict(daily_row).get("day_key", "")).strip()[:16] + if not day_key: + try: + today = self.ensure_season_daily_game_card(season_id) + if str(today.get("card_id", "")).strip() == card_id: + day_key = str(today.get("day_key", "")).strip()[:16] + except ValueError: + day_key = "" + with self._lock, self._connect() as conn: + row = conn.execute( + """ + SELECT id + FROM season_room_progress_logs + WHERE user_id=? AND season_id=? AND room_id=? + """, + (safe_user_id, season_id, room_id), + ).fetchone() + if row is not None: + return {"applied": False, "events": [], "task_updates": [], "card_updates": [], "season_id": season_id} + conn.execute( + """ + INSERT INTO season_room_progress_logs(user_id, season_id, room_id, created_at) + VALUES (?, ?, ?, ?) + """, + (safe_user_id, season_id, room_id, utc_now_iso()), + ) + conn.commit() + + events: list[tuple[str, int]] = [("complete_game", 1)] + ending_state = str(summary.get("ending_state", "")).strip().lower() + if ending_state in {"escaped", "victory", "success", "win", "pact", "sacrifice", "custom_end"}: + events.append(("win_game", 1)) + actions = self._to_int(summary.get("total_actions", 0), 0, 0) + if actions > 0: + events.append(("submit_action", actions)) + rounds = self._to_int(summary.get("rounds", 0), 0, 0) + if rounds > 0: + events.append(("round_count", rounds)) + player_rows = summary.get("players", []) + if isinstance(player_rows, list): + for row in player_rows: + if not isinstance(row, dict): + continue + username = str(row.get("username", "")).strip() + if not username: + continue + if bool(row.get("is_mvp")): + events.append(("mvp_count", 1)) + break + task_updates: list[dict[str, Any]] = [] + card_updates: list[dict[str, Any]] = [] + for event_key, event_amount in events: + task_res = self.progress_user_season_tasks( + safe_user_id, + season_id, + event_key=event_key, + amount=event_amount, + context={"room_id": room_id}, + ) + card_res = self.progress_user_season_cards( + safe_user_id, + season_id, + event_key=event_key, + amount=event_amount, + ) + task_updates.append({"event_key": event_key, "updated": task_res.get("updated", []), "completed_ids": task_res.get("completed_ids", [])}) + card_updates.append({"event_key": event_key, "updated": card_res.get("updated", []), "completed_ids": card_res.get("completed_ids", [])}) + + daily_completion = None + if day_key: + try: + daily_completion = self.mark_season_daily_card_completed( + safe_user_id, + season_id, + day_key=day_key, + room_id=room_id, + ending_state=ending_state, + ending_summary=str(summary.get("ending_summary", "")), + ) + except ValueError: + daily_completion = None + return { + "applied": True, + "season_id": season_id, + "daily_card": {"day_key": day_key, "completed": bool(daily_completion)} if day_key else {"day_key": "", "completed": False}, + "events": [{"event_key": key, "amount": amount} for key, amount in events], + "task_updates": task_updates, + "card_updates": card_updates, + } + + def get_season_dashboard(self, season_id: str) -> dict[str, Any]: + safe_season_id = str(season_id or "").strip() + if not safe_season_id: + return { + "participants": 0, + "premium_users": 0, + "premium_conversion": 0.0, + "avg_level": 0.0, + "task_claimed": 0, + "card_claimed": 0, + } + with self._lock, self._connect() as conn: + participants_row = conn.execute( + "SELECT COUNT(*) AS c FROM user_season_profiles WHERE season_id=?", + (safe_season_id,), + ).fetchone() + premium_row = conn.execute( + "SELECT COUNT(*) AS c FROM user_season_profiles WHERE season_id=? AND pass_type='premium'", + (safe_season_id,), + ).fetchone() + level_row = conn.execute( + "SELECT COALESCE(AVG(level), 0) AS avg_level FROM user_season_profiles WHERE season_id=?", + (safe_season_id,), + ).fetchone() + task_row = conn.execute( + "SELECT COUNT(*) AS c FROM user_season_tasks WHERE season_id=? AND status='claimed'", + (safe_season_id,), + ).fetchone() + card_row = conn.execute( + "SELECT COUNT(*) AS c FROM season_activity_cards WHERE season_id=? AND status='claimed'", + (safe_season_id,), + ).fetchone() + participants = self._to_int(dict(participants_row or {}).get("c", 0), 0, 0) + premium_users = self._to_int(dict(premium_row or {}).get("c", 0), 0, 0) + conversion = round((premium_users / participants) * 100, 2) if participants else 0.0 + avg_level = round(float(dict(level_row or {}).get("avg_level", 0.0) or 0.0), 2) + task_claimed = self._to_int(dict(task_row or {}).get("c", 0), 0, 0) + card_claimed = self._to_int(dict(card_row or {}).get("c", 0), 0, 0) + return { + "participants": participants, + "premium_users": premium_users, + "premium_conversion": conversion, + "avg_level": avg_level, + "task_claimed": task_claimed, + "card_claimed": card_claimed, + } + + def get_season_leaderboard(self, season_id: str, *, limit: int = 30) -> list[dict[str, Any]]: + safe_season_id = str(season_id or "").strip() + safe_limit = max(1, min(500, int(limit))) + if not safe_season_id: + return [] + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT + p.user_id, + p.level, + p.xp, + p.season_points, + p.completed_tasks, + p.total_claimed_cards, + p.pass_type, + u.username, + u.display_name, + u.avatar_color, + u.avatar_url + FROM user_season_profiles p + JOIN users u ON u.user_id = p.user_id + WHERE p.season_id=? + ORDER BY p.season_points DESC, p.level DESC, p.xp DESC, p.completed_tasks DESC + LIMIT ? + """, + (safe_season_id, safe_limit), + ).fetchall() + out: list[dict[str, Any]] = [] + for idx, row in enumerate(rows, start=1): + item = dict(row) + item["rank"] = idx + item["level"] = self._to_int(item.get("level", 1), 1, 1) + item["xp"] = self._to_int(item.get("xp", 0), 0, 0) + item["season_points"] = self._to_int(item.get("season_points", 0), 0, 0) + item["completed_tasks"] = self._to_int(item.get("completed_tasks", 0), 0, 0) + item["total_claimed_cards"] = self._to_int(item.get("total_claimed_cards", 0), 0, 0) + out.append(item) + return out + def get_oauth_identity(self, provider: str, provider_user_id: str) -> dict[str, Any] | None: safe_provider = str(provider or "").strip().lower() safe_provider_user_id = str(provider_user_id or "").strip() @@ -2902,6 +6575,7 @@ class SQLitePersistence: def _build_card_payload(self, row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: item = dict(row) + item["world_id"] = str(item.get("world_id", "global")).strip()[:64] or "global" try: item["starter_goals"] = json.loads(item.get("starter_goals", "[]")) except json.JSONDecodeError: @@ -2917,14 +6591,25 @@ class SQLitePersistence: item["is_official"] = bool(item.get("is_official", 0)) return item - def list_game_cards(self) -> list[dict[str, Any]]: + def list_game_cards(self, *, world_id: str = "") -> list[dict[str, Any]]: + safe_world_id = str(world_id or "").strip()[:64] with self._lock, self._connect() as conn: - rows = conn.execute( - """ - SELECT * FROM game_cards - ORDER BY is_official DESC, created_at DESC - """ - ).fetchall() + if safe_world_id: + rows = conn.execute( + """ + SELECT * FROM game_cards + WHERE world_id=? + ORDER BY is_official DESC, created_at DESC + """, + (safe_world_id,), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT * FROM game_cards + ORDER BY is_official DESC, created_at DESC + """ + ).fetchall() return [self._build_card_payload(row) for row in rows] def get_game_card(self, card_id: str) -> dict[str, Any] | None: @@ -2940,6 +6625,7 @@ class SQLitePersistence: def create_game_card( self, *, + world_id: str = "global", title: str, subtitle: str, summary: str, @@ -2950,6 +6636,7 @@ class SQLitePersistence: content_pack: dict[str, Any] | None, creator_user_id: str, ) -> dict[str, Any]: + safe_world_id = str(world_id or "global").strip()[:64] or "global" base_slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "card" card_id = base_slug[:32] suffix = 2 @@ -2962,12 +6649,13 @@ class SQLitePersistence: conn.execute( """ INSERT INTO game_cards( - card_id, title, subtitle, summary, cover_url, opening_text, + card_id, world_id, title, subtitle, summary, cover_url, opening_text, starter_goals, state_schema, content_pack, creator_user_id, is_official, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( card_id, + safe_world_id, title, subtitle, summary, @@ -3506,7 +7194,7 @@ class SQLitePersistence: if remain <= 0: break lot_id = int(row["lot_id"]) - available = self._to_int(row.get("remaining_amount", 0), 0, 0) + available = self._to_int(row["remaining_amount"], 0, 0) if available <= 0: continue spend = min(available, remain) diff --git a/static/css/style.css b/static/css/style.css index 437ae65..449d259 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -387,6 +387,12 @@ button:disabled { backdrop-filter: blur(6px); } +.chronicle-scroll { + max-height: min(70vh, 860px); + overflow: auto; + padding-right: 0.25rem; +} + .status-banner:not(:empty) { display: block; } @@ -1740,6 +1746,117 @@ body.room-page .meta-panel li { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +body.season-page { + background: + radial-gradient(circle at 10% -8%, rgba(61, 110, 255, 0.2) 0, transparent 34%), + radial-gradient(circle at 92% 6%, rgba(255, 96, 125, 0.18) 0, transparent 32%), + linear-gradient(180deg, #ebf1ff 0%, #e8f0ff 52%, #edf4ff 100%); +} + +body.season-admin-page { + background: + radial-gradient(circle at 8% -10%, rgba(0, 188, 157, 0.2) 0, transparent 34%), + radial-gradient(circle at 92% 5%, rgba(77, 125, 255, 0.2) 0, transparent 32%), + linear-gradient(180deg, #e9f3ff 0%, #eff6ff 56%, #e8f1ff 100%); +} + +.season-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; +} + +.season-grid > .card:first-child { + grid-column: 1 / -1; +} + +.season-hero { + padding: 0; + overflow: hidden; + display: grid; + grid-template-columns: minmax(220px, 0.52fr) minmax(0, 1fr); + border-radius: 22px; +} + +.season-hero-cover { + min-height: 220px; + background: + radial-gradient(circle at 18% 12%, rgba(255, 90, 95, 0.22), transparent 36%), + radial-gradient(circle at 85% 8%, rgba(77, 125, 255, 0.22), transparent 34%), + linear-gradient(160deg, rgba(12, 25, 55, 0.58), rgba(12, 25, 55, 0.32)), + var(--season-cover); + background-size: cover; + background-position: center; + border-right: 1px solid rgba(160, 183, 235, 0.54); +} + +.season-hero-body { + padding: 1rem; +} + +.season-hero-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.9rem; + flex-wrap: wrap; + margin-bottom: 0.6rem; +} + +.season-hero-cta { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.season-hero-cta button { + width: auto; +} + +.season-daily-card { + border: 1px dashed rgba(127, 160, 228, 0.55); + border-radius: 14px; + background: rgba(246, 250, 255, 0.82); + padding: 0.7rem 0.75rem; + display: grid; + gap: 0.45rem; +} + +.season-daily-head { + font-weight: 900; + letter-spacing: 0.2px; + color: #20335c; +} + +.season-admin-grid { + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + align-items: start; +} + +.season-admin-grid > .card:last-child { + grid-column: 1 / -1; +} + +.season-grid .compact-list li, +.season-admin-grid .compact-list li { + padding: 0.56rem 0.62rem; +} + +#season-system-config { + margin-bottom: 0.36rem; +} + +#season-admin-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + flex-wrap: wrap; +} + +#summary-season-progress { + margin-top: 0.2rem; +} + .achievement-rows { display: grid; gap: 0.55rem; @@ -2331,6 +2448,26 @@ body[data-theme="sunset"].room-page .meta-panel li { .invite-grid { grid-template-columns: 1fr; } + + .season-grid, + .season-admin-grid { + grid-template-columns: 1fr; + } + + .season-grid > .card:first-child, + .season-admin-grid > .card:last-child { + grid-column: auto; + } + + .season-hero { + grid-template-columns: 1fr; + } + + .season-hero-cover { + min-height: 180px; + border-right: none; + border-bottom: 1px solid rgba(160, 183, 235, 0.54); + } } @media (max-width: 760px) { diff --git a/static/js/room_summary.js b/static/js/room_summary.js index 0595d07..a454ac5 100644 --- a/static/js/room_summary.js +++ b/static/js/room_summary.js @@ -5,6 +5,7 @@ const overviewEl = document.getElementById('summary-overview'); const mvpEl = document.getElementById('summary-mvp'); const playersEl = document.getElementById('summary-players'); + const seasonProgressEl = document.getElementById('summary-season-progress'); const statusEl = document.getElementById('status'); const setStatus = (text) => { @@ -122,6 +123,51 @@ }); }; + const renderSeasonProgress = (summary) => { + if (!seasonProgressEl) return; + seasonProgressEl.textContent = ''; + const progress = summary && typeof summary === 'object' ? summary.season_progress : null; + if (!progress || typeof progress !== 'object') { + const hint = document.createElement('div'); + hint.className = 'helper-text'; + hint.textContent = '登录后在对局结算时会自动推进赛季任务与活动卡片进度。'; + seasonProgressEl.appendChild(hint); + return; + } + + const events = Array.isArray(progress.events) ? progress.events : []; + const eventText = events.length + ? events.map((item) => `${item.event_key || '-'} +${Number(item.amount || 0)}`).join('、') + : '无'; + + const taskSet = new Set(); + const taskUpdates = Array.isArray(progress.task_updates) ? progress.task_updates : []; + taskUpdates.forEach((entry) => { + const completed = (entry && Array.isArray(entry.completed_ids)) ? entry.completed_ids : []; + completed.forEach((taskId) => taskSet.add(Number(taskId))); + }); + + const cardSet = new Set(); + const cardUpdates = Array.isArray(progress.card_updates) ? progress.card_updates : []; + cardUpdates.forEach((entry) => { + const completed = (entry && Array.isArray(entry.completed_ids)) ? entry.completed_ids : []; + completed.forEach((cardId) => cardSet.add(Number(cardId))); + }); + + const rows = [ + `赛季ID:${progress.season_id || '-'}`, + `结算写入:${progress.applied ? '已写入' : '已处理过或当前无可用赛季'}`, + `触发事件:${eventText}`, + `任务推进:${taskUpdates.length} 项,新增完成 ${taskSet.size} 个`, + `卡片推进:${cardUpdates.length} 项,新增完成 ${cardSet.size} 个`, + ]; + rows.forEach((text) => { + const div = document.createElement('div'); + div.textContent = text; + seasonProgressEl.appendChild(div); + }); + }; + const load = async () => { if (!roomId) { setStatus('房间不存在'); @@ -139,6 +185,7 @@ renderOverview(summary); renderMvp(summary); renderPlayers(summary); + renderSeasonProgress(summary); }; load().catch((error) => setStatus(`网络错误: ${error}`)); diff --git a/static/js/season.js b/static/js/season.js new file mode 100644 index 0000000..a827ea7 --- /dev/null +++ b/static/js/season.js @@ -0,0 +1,647 @@ +(function () { + const body = document.body; + const subtitleEl = document.getElementById('season-subtitle'); + const bannerEl = document.getElementById('season-banner'); + const profileEl = document.getElementById('season-profile'); + const announcementsEl = document.getElementById('season-announcements'); + const cardsEl = document.getElementById('season-cards'); + const tasksEl = document.getElementById('season-tasks'); + const rewardsEl = document.getElementById('season-rewards'); + const decorationsEl = document.getElementById('season-decorations'); + const heroCoverEl = document.getElementById('season-hero-cover'); + const heroTitleEl = document.getElementById('season-hero-title'); + const heroSubEl = document.getElementById('season-hero-sub'); + const dailyCardEl = document.getElementById('season-daily-card'); + const dailyStoryEl = document.getElementById('season-daily-story'); + const dailyStoryHintEl = document.getElementById('season-daily-story-hint'); + const intelEl = document.getElementById('season-intel'); + const intelUnlockBtn = document.getElementById('season-intel-unlock-btn'); + const statusEl = document.getElementById('status'); + const cardRefreshHintEl = document.getElementById('season-card-refresh-hint'); + const passUnlockBtn = document.getElementById('season-pass-unlock-btn'); + const claimAllTasksBtn = document.getElementById('season-claim-all-tasks-btn'); + const chroniclePinEl = document.getElementById('season-chronicle-pin'); + const seasonSwitchEl = document.getElementById('season-switch'); + const seasonSwitchCardsEl = document.getElementById('season-switch-cards'); + const seasonSwitchChronicleEl = document.getElementById('season-switch-chronicle'); + + const tabAllBtn = document.getElementById('season-task-tab-all'); + const tabDailyBtn = document.getElementById('season-task-tab-daily'); + const tabWeeklyBtn = document.getElementById('season-task-tab-weekly'); + const tabSeasonBtn = document.getElementById('season-task-tab-season'); + + let currentTaskFilter = ''; + let cachedPayload = null; + + const setStatus = (text) => { + if (statusEl) statusEl.textContent = text || ''; + }; + + const getJson = async (url) => { + const res = await fetch(url); + return res.json(); + }; + + const postJson = async (url, payload = {}) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); + }; + + const getCardDetail = async (cardId) => { + const safeId = String(cardId || '').trim(); + if (!safeId) return null; + const res = await fetch(`/api/cards/${encodeURIComponent(safeId)}`); + const data = await res.json(); + if (!data.ok) return null; + return data.card || null; + }; + + const levelBadge = (rarity) => { + const key = String(rarity || '').toLowerCase(); + if (key === 'legendary') return '传说'; + if (key === 'epic') return '史诗'; + if (key === 'rare') return '稀有'; + return '普通'; + }; + + const formatCountdown = (nextRefreshAt) => { + const text = String(nextRefreshAt || '').trim(); + if (!text) return '下次刷新时间:-'; + const nextTs = Date.parse(text); + if (!Number.isFinite(nextTs)) return `下次刷新时间:${text}`; + const diff = Math.max(0, nextTs - Date.now()); + const hour = Math.floor(diff / (1000 * 60 * 60)); + const minute = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + return `距下次刷新约 ${hour} 小时 ${minute} 分钟(${text})`; + }; + + const renderBanner = (payload) => { + if (!bannerEl) return; + const season = payload.season || {}; + const profile = payload.profile || {}; + bannerEl.textContent = ''; + if (heroCoverEl) { + const cover = String(season.cover_url || '').trim(); + if (cover) heroCoverEl.style.setProperty('--season-cover', `url('${cover.replace(/'/g, "\\'")}')`); + } + const rows = [ + `赛季:${season.name || '-'}`, + `主题:${season.theme || '-'}`, + `状态:${season.time_status || '-'}`, + `时间:${season.start_at || '-'} ~ ${season.end_at || '-'}`, + season.rest_end_at ? `休整期截止:${season.rest_end_at}` : '', + `通行证价格:${Number(season.pass_price || 0)} 积分`, + `赛季等级:Lv.${Number(profile.level || 1)}(总经验 ${Number(profile.xp || 0)})`, + `赛季币:${Number(profile.season_points || 0)}`, + ].filter(Boolean); + rows.forEach((text) => { + const div = document.createElement('div'); + div.textContent = text; + bannerEl.appendChild(div); + }); + const titleText = `${season.name || '赛季'} · ${season.theme || '未命名主题'}`; + if (subtitleEl) subtitleEl.textContent = titleText; + if (heroTitleEl) heroTitleEl.textContent = season.name || '赛季中心'; + if (heroSubEl) heroSubEl.textContent = titleText; + if (cardRefreshHintEl) { + cardRefreshHintEl.textContent = formatCountdown(payload.next_refresh_at || ''); + } + }; + + const renderSeasonCardCenter = (payload) => { + const season = payload.season || {}; + const seasonId = String(season.season_id || '').trim(); + if (!seasonId) return; + const parent = dailyCardEl ? dailyCardEl.parentElement : null; + if (!parent) return; + let box = document.getElementById('season-card-center'); + if (!box) { + box = document.createElement('div'); + box.id = 'season-card-center'; + box.className = 'admin-inline-actions'; + box.style.marginTop = '0.4rem'; + parent.insertBefore(box, dailyCardEl); + } + box.textContent = ''; + const link = document.createElement('a'); + link.className = 'link-btn'; + link.href = `/season/${encodeURIComponent(seasonId)}/cards`; + link.textContent = '进入本赛季游戏卡列表'; + box.appendChild(link); + }; + + const renderProfile = (payload) => { + if (!profileEl) return; + const profile = payload.profile || {}; + profileEl.textContent = ''; + const rows = [ + `通行证:${profile.pass_type === 'premium' ? '高级档' : '免费档'}`, + `当前等级:${Number(profile.level || 1)}`, + `本级经验:${Number(profile.xp_in_current_level || 0)} / ${Number(profile.xp_per_level || 100)}`, + `距离下一级:${Number(profile.xp_to_next_level || 0)}`, + `已完成任务:${Number(profile.completed_tasks || 0)}`, + `已领取卡片:${Number(profile.total_claimed_cards || 0)}`, + ]; + rows.forEach((text) => { + const div = document.createElement('div'); + div.textContent = text; + profileEl.appendChild(div); + }); + if (passUnlockBtn) { + passUnlockBtn.disabled = profile.pass_type === 'premium'; + passUnlockBtn.textContent = profile.pass_type === 'premium' ? '已解锁高级通行证' : '解锁高级通行证'; + } + }; + + const renderAnnouncements = (rows) => { + if (!announcementsEl) return; + announcementsEl.textContent = ''; + const list = Array.isArray(rows) ? rows : []; + if (!list.length) { + const li = document.createElement('li'); + li.textContent = '暂无赛季公告'; + announcementsEl.appendChild(li); + return; + } + list.forEach((row) => { + const li = document.createElement('li'); + li.textContent = `${row.title || '公告'} · ${row.created_at || '-'} · ${row.content || ''}`; + announcementsEl.appendChild(li); + }); + }; + + const buildActionBtn = (text, onClick, className = 'secondary-btn') => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = className; + btn.textContent = text; + btn.addEventListener('click', onClick); + return btn; + }; + + const renderCards = (rows, payload) => { + if (!cardsEl) return; + cardsEl.textContent = ''; + const list = Array.isArray(rows) ? rows : []; + if (!list.length) { + const li = document.createElement('li'); + li.textContent = '当前刷新槽位暂无卡片,稍后重试。'; + cardsEl.appendChild(li); + return; + } + list.forEach((row) => { + const li = document.createElement('li'); + const head = document.createElement('div'); + head.textContent = `【${levelBadge(row.rarity)}】${row.title || '未命名卡片'} · ${row.status || 'active'}`; + li.appendChild(head); + + const story = document.createElement('p'); + story.className = 'helper-text'; + story.textContent = row.story_text || ''; + li.appendChild(story); + + const meta = document.createElement('p'); + meta.className = 'helper-text'; + meta.textContent = `目标:${row.objective || '-'} | 进度 ${Number(row.progress_value || 0)}/${Number(row.target_value || 1)} | 奖励 ${Number(row.reward_points || 0)} 积分 + ${Number(row.reward_xp || 0)} 经验`; + li.appendChild(meta); + + const actions = document.createElement('div'); + actions.className = 'admin-inline-actions'; + if (String(row.status || '') === 'completed') { + actions.appendChild(buildActionBtn('领取奖励', async () => { + setStatus(`正在领取卡片奖励 #${row.id}...`); + const data = await postJson(`/api/seasons/active/cards/${row.id}/claim`, {}); + if (!data.ok) { + setStatus(data.error || '领取失败'); + return; + } + setStatus('卡片奖励领取成功。'); + await reload(); + })); + } + if (String(row.status || '') === 'active') { + actions.appendChild(buildActionBtn('手动刷新', async () => { + setStatus(`正在刷新卡片 #${row.id}...`); + const data = await postJson(`/api/seasons/active/cards/${row.id}/refresh`, {}); + if (!data.ok) { + setStatus(data.error || '刷新失败'); + return; + } + setStatus('卡片已刷新。'); + await reload(); + }, 'ghost-btn')); + } + if (actions.children.length) li.appendChild(actions); + cardsEl.appendChild(li); + }); + if (cardRefreshHintEl) { + cardRefreshHintEl.textContent = formatCountdown(payload.next_refresh_at || ''); + } + }; + + const renderTasks = (rows) => { + if (!tasksEl) return; + tasksEl.textContent = ''; + const list = Array.isArray(rows) ? rows : []; + const filtered = currentTaskFilter + ? list.filter((row) => String(row.task_type || '') === currentTaskFilter) + : list; + if (!filtered.length) { + const li = document.createElement('li'); + li.textContent = '暂无任务'; + tasksEl.appendChild(li); + return; + } + filtered.forEach((row) => { + const li = document.createElement('li'); + const title = document.createElement('div'); + title.textContent = `[${row.task_type || '-'}] ${row.title || '未命名任务'} · ${row.status || 'active'}`; + li.appendChild(title); + const desc = document.createElement('p'); + desc.className = 'helper-text'; + desc.textContent = `${row.description || ''} | 进度 ${Number(row.progress_value || 0)}/${Number(row.target_value || 1)} | 奖励 ${Number(row.reward_points || 0)} 积分 + ${Number(row.reward_xp || 0)} 经验`; + li.appendChild(desc); + if (String(row.status || '') === 'completed') { + li.appendChild(buildActionBtn('领取任务奖励', async () => { + setStatus(`正在领取任务 #${row.id}...`); + const data = await postJson(`/api/seasons/active/tasks/${row.id}/claim`, {}); + if (!data.ok) { + setStatus(data.error || '领取失败'); + return; + } + setStatus('任务奖励领取成功。'); + await reload(); + })); + } + tasksEl.appendChild(li); + }); + }; + + const renderRewards = (rows, claimedIds, profile) => { + if (!rewardsEl) return; + rewardsEl.textContent = ''; + const list = Array.isArray(rows) ? rows : []; + const claimedSet = new Set(Array.isArray(claimedIds) ? claimedIds : []); + const level = Number(profile?.level || 1); + if (!list.length) { + const li = document.createElement('li'); + li.textContent = '暂无通行证奖励配置'; + rewardsEl.appendChild(li); + return; + } + list.slice(0, 120).forEach((row) => { + const rewardId = String(row.reward_id || ''); + const li = document.createElement('li'); + const locked = level < Number(row.reward_level || 1); + const claimed = claimedSet.has(rewardId); + const title = `${row.title || rewardId} · Lv.${Number(row.reward_level || 1)} · ${row.reward_track || 'free'}`; + li.textContent = `${title} | 奖励 ${Number(row.reward_points || 0)} 积分`; + if (!locked && !claimed) { + li.appendChild(buildActionBtn('领取', async () => { + setStatus(`正在领取赛季奖励 ${rewardId}...`); + const data = await postJson(`/api/seasons/active/rewards/${encodeURIComponent(rewardId)}/claim`, {}); + if (!data.ok) { + setStatus(data.error || '领取失败'); + return; + } + setStatus('赛季奖励领取成功。'); + await reload(); + })); + } else { + const mark = document.createElement('span'); + mark.className = 'helper-text'; + mark.style.marginLeft = '0.5rem'; + mark.textContent = claimed ? '已领取' : '未解锁'; + li.appendChild(mark); + } + rewardsEl.appendChild(li); + }); + }; + + const renderDecorations = (rows, equipped) => { + if (!decorationsEl) return; + decorationsEl.textContent = ''; + const list = Array.isArray(rows) ? rows : []; + if (!list.length) { + const li = document.createElement('li'); + li.textContent = '暂未获得赛季装扮'; + decorationsEl.appendChild(li); + return; + } + const equippedTitleId = String((equipped?.title || {}).decoration_id || ''); + const equippedFrameId = String((equipped?.avatar_frame || {}).decoration_id || ''); + list.forEach((row) => { + const type = String(row.decoration_type || ''); + const decorationId = String(row.decoration_id || ''); + const isEquipped = (type === 'title' && decorationId === equippedTitleId) + || (type === 'avatar_frame' && decorationId === equippedFrameId) + || Boolean(row.is_equipped); + const li = document.createElement('li'); + li.textContent = `${type === 'title' ? '称号' : '头像框'}:${decorationId}${isEquipped ? '(已佩戴)' : ''}`; + if (!isEquipped) { + li.appendChild(buildActionBtn('佩戴', async () => { + setStatus(`正在佩戴 ${decorationId}...`); + const data = await postJson('/api/seasons/decorations/equip', { + decoration_type: type, + decoration_id: decorationId, + }); + if (!data.ok) { + setStatus(data.error || '佩戴失败'); + return; + } + setStatus('佩戴成功。'); + await reload(); + }, 'ghost-btn')); + } + decorationsEl.appendChild(li); + }); + }; + + const renderDailyCard = (payload) => { + if (!dailyCardEl) return; + dailyCardEl.textContent = ''; + const daily = payload.daily_game_card || {}; + const title = String(daily.title || '').trim() || '今日赛季日卡未生成'; + const summary = String(daily.summary || '').trim(); + const cardId = String(daily.card_id || '').trim(); + const head = document.createElement('div'); + head.className = 'season-daily-head'; + head.textContent = title; + dailyCardEl.appendChild(head); + if (summary) { + const p = document.createElement('p'); + p.className = 'helper-text'; + p.textContent = summary; + dailyCardEl.appendChild(p); + } + if (cardId) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'secondary-btn'; + btn.textContent = '用今日游戏卡开一局'; + btn.addEventListener('click', async () => { + setStatus('正在创建房间...'); + const data = await postJson('/api/rooms/create', { card_id: cardId }); + if (!data.ok) { + setStatus(data.error || '创建失败'); + return; + } + if (data.player_id) localStorage.setItem(`echo-player-${data.room_id}`, data.player_id); + if (data.player_name) localStorage.setItem('echo-last-name', data.player_name); + window.location.href = data.redirect; + }); + dailyCardEl.appendChild(btn); + } + + const hint = document.createElement('p'); + hint.className = 'helper-text'; + hint.textContent = '提示:通关这张日卡后,才能解锁当日资料。'; + dailyCardEl.appendChild(hint); + }; + + const renderDailyStory = async (payload) => { + if (!dailyStoryEl) return; + dailyStoryEl.textContent = ''; + const daily = payload.daily_game_card || {}; + const cardId = String(daily.card_id || '').trim(); + if (!cardId) { + dailyStoryEl.textContent = '今日故事未生成'; + return; + } + const card = await getCardDetail(cardId); + if (!card) { + dailyStoryEl.textContent = '今日故事加载失败'; + return; + } + + const title = document.createElement('div'); + title.className = 'season-daily-head'; + title.textContent = `“${card.title || '未命名故事'}”`; + dailyStoryEl.appendChild(title); + + const summary = document.createElement('p'); + summary.className = 'helper-text'; + summary.textContent = card.summary || ''; + dailyStoryEl.appendChild(summary); + + const goals = Array.isArray(card.starter_goals) ? card.starter_goals : []; + const objective = document.createElement('p'); + objective.className = 'helper-text'; + objective.textContent = goals[0] || '探索今日线索并推进剧情'; + dailyStoryEl.appendChild(objective); + + const reward = document.createElement('div'); + reward.className = 'helper-text'; + reward.textContent = '奖励:赛季经验/积分结算'; + dailyStoryEl.appendChild(reward); + + const actions = document.createElement('div'); + actions.className = 'admin-inline-actions'; + + const acceptBtn = document.createElement('button'); + acceptBtn.type = 'button'; + acceptBtn.className = 'secondary-btn'; + acceptBtn.textContent = '接受挑战'; + acceptBtn.addEventListener('click', async () => { + setStatus('正在创建房间...'); + const data = await postJson('/api/rooms/create', { card_id: cardId }); + if (!data.ok) { + setStatus(data.error || '创建失败'); + return; + } + if (data.player_id) localStorage.setItem(`echo-player-${data.room_id}`, data.player_id); + if (data.player_name) localStorage.setItem('echo-last-name', data.player_name); + window.location.href = data.redirect; + }); + + const refreshBtn = document.createElement('button'); + refreshBtn.type = 'button'; + refreshBtn.className = 'ghost-btn'; + refreshBtn.textContent = '刷新'; + refreshBtn.addEventListener('click', async () => { + setStatus('正在刷新今日故事...'); + await reload(); + setStatus('今日故事已刷新。'); + }); + + actions.appendChild(acceptBtn); + actions.appendChild(refreshBtn); + dailyStoryEl.appendChild(actions); + + if (dailyStoryHintEl) { + const locked = Array.isArray(daily.locked_intel) ? daily.locked_intel.length : 0; + const unlocked = Array.isArray(payload.intel) + ? payload.intel.filter((it) => String(it.day_key || '') === String(daily.day_key || '')).length + : 0; + const pending = Math.max(0, locked - unlocked); + dailyStoryHintEl.textContent = pending ? `还有 ${pending} 条线索待查看 →` : '今日线索已全部查看'; + } + }; + + const renderIntel = (payload) => { + if (!intelEl) return; + intelEl.textContent = ''; + const rows = Array.isArray(payload.intel) ? payload.intel : []; + if (!rows.length) { + const li = document.createElement('li'); + li.textContent = '暂无资料,先解锁一条。'; + intelEl.appendChild(li); + return; + } + rows.slice(0, 80).forEach((row) => { + const li = document.createElement('li'); + li.textContent = `${row.intel_title || '资料'} · ${row.day_key || '-'} · ${row.intel_text || ''}`; + intelEl.appendChild(li); + }); + }; + + const renderTaskTabs = () => { + const update = () => { + [tabAllBtn, tabDailyBtn, tabWeeklyBtn, tabSeasonBtn].forEach((btn) => { + if (!btn) return; + btn.classList.remove('secondary-btn'); + }); + if (!currentTaskFilter && tabAllBtn) tabAllBtn.classList.add('secondary-btn'); + if (currentTaskFilter === 'daily' && tabDailyBtn) tabDailyBtn.classList.add('secondary-btn'); + if (currentTaskFilter === 'weekly' && tabWeeklyBtn) tabWeeklyBtn.classList.add('secondary-btn'); + if (currentTaskFilter === 'season' && tabSeasonBtn) tabSeasonBtn.classList.add('secondary-btn'); + if (cachedPayload) renderTasks(cachedPayload.tasks || []); + }; + if (tabAllBtn) tabAllBtn.addEventListener('click', () => { currentTaskFilter = ''; update(); }); + if (tabDailyBtn) tabDailyBtn.addEventListener('click', () => { currentTaskFilter = 'daily'; update(); }); + if (tabWeeklyBtn) tabWeeklyBtn.addEventListener('click', () => { currentTaskFilter = 'weekly'; update(); }); + if (tabSeasonBtn) tabSeasonBtn.addEventListener('click', () => { currentTaskFilter = 'season'; update(); }); + update(); + }; + + const bindTopActions = () => { + if (passUnlockBtn) { + passUnlockBtn.addEventListener('click', async () => { + setStatus('正在解锁高级通行证...'); + const data = await postJson('/api/seasons/active/pass/unlock', {}); + if (!data.ok) { + setStatus(data.error || '解锁失败'); + return; + } + setStatus('高级通行证已解锁。'); + await reload(); + }); + } + if (claimAllTasksBtn) { + claimAllTasksBtn.addEventListener('click', async () => { + setStatus('正在一键领取任务奖励...'); + const data = await postJson('/api/seasons/active/tasks/claim-all', {}); + if (!data.ok) { + setStatus(data.error || '领取失败'); + return; + } + setStatus(`已领取 ${Number((data.result || {}).claimed_count || 0)} 个任务奖励。`); + await reload(); + }); + } + }; + + const bindIntelActions = () => { + if (!intelUnlockBtn) return; + intelUnlockBtn.addEventListener('click', async () => { + const payload = cachedPayload || {}; + const daily = payload.daily_game_card || {}; + const dayKey = String(daily.day_key || '').trim(); + if (!dayKey) { + setStatus('今日赛季日卡尚未生成,稍后再试。'); + return; + } + setStatus('正在解锁资料...'); + const data = await postJson('/api/seasons/active/intel/unlock', { day_key: dayKey }); + if (!data.ok) { + if (String(data.error || '').includes('通关')) { + setStatus(data.error); + } else { + setStatus(data.error || '解锁失败'); + } + return; + } + setStatus(`已解锁资料:${(data.unlocked || {}).intel_title || ''}`); + await reload(); + }); + }; + + const renderAll = (payload) => { + cachedPayload = payload || {}; + renderBanner(payload || {}); + renderProfile(payload || {}); + renderAnnouncements(payload.announcements || []); + renderSeasonCardCenter(payload || {}); + bindSeasonSwitch(payload || {}); + renderDailyStory(payload || {}).catch((error) => setStatus(`网络错误: ${error}`)); + renderDailyCard(payload || {}); + renderIntel(payload || {}); + renderCards(payload.cards || [], payload || {}); + renderTasks(payload.tasks || []); + renderRewards(payload.rewards || [], payload.claimed_reward_ids || [], payload.profile || {}); + renderDecorations(payload.decorations || [], payload.equipped_decorations || {}); + const timeStatus = String((payload.season || {}).time_status || ''); + if (chroniclePinEl) { + chroniclePinEl.style.display = timeStatus === 'rest' ? '' : 'none'; + } + }; + + const bindSeasonSwitch = (payload) => { + if (!seasonSwitchEl || !seasonSwitchCardsEl || !seasonSwitchChronicleEl) return; + if (seasonSwitchEl.dataset.bound === '1') return; + seasonSwitchEl.dataset.bound = '1'; + + const applyLinks = () => { + const seasonId = String(seasonSwitchEl.value || '').trim(); + seasonSwitchCardsEl.href = seasonId ? `/season/${encodeURIComponent(seasonId)}/cards` : '#'; + seasonSwitchChronicleEl.href = seasonId ? `/season/${encodeURIComponent(seasonId)}/chronicle` : '#'; + seasonSwitchCardsEl.classList.toggle('disabled', !seasonId); + seasonSwitchChronicleEl.classList.toggle('disabled', !seasonId); + }; + + seasonSwitchEl.addEventListener('change', applyLinks); + applyLinks(); + + (async () => { + const res = await getJson('/api/seasons/history'); + if (!res.ok) { + setStatus(res.error || '加载赛季列表失败'); + return; + } + const seasons = Array.isArray(res.seasons) ? res.seasons : []; + const currentId = String((payload.season || {}).season_id || '').trim(); + seasonSwitchEl.textContent = ''; + seasons.slice(0, 60).forEach((s) => { + const sid = String((s || {}).season_id || '').trim(); + if (!sid) return; + const opt = document.createElement('option'); + const name = String((s || {}).name || '').trim() || '未命名赛季'; + const theme = String((s || {}).theme || '').trim(); + const status = String((s || {}).time_status || '').trim(); + opt.value = sid; + opt.textContent = `${name}${theme ? ` · ${theme}` : ''}${status ? ` · ${status}` : ''}`; + if (sid === currentId) opt.selected = true; + seasonSwitchEl.appendChild(opt); + }); + applyLinks(); + })().catch((error) => setStatus(`网络错误: ${error}`)); + }; + + const reload = async () => { + const data = await getJson('/api/seasons/active'); + if (!data.ok) { + setStatus(data.error || '加载赛季信息失败'); + return; + } + renderAll(data); + }; + + bindTopActions(); + bindIntelActions(); + renderTaskTabs(); + reload().catch((error) => setStatus(`网络错误: ${error}`)); +})(); diff --git a/static/js/season_admin.js b/static/js/season_admin.js new file mode 100644 index 0000000..2ba720e --- /dev/null +++ b/static/js/season_admin.js @@ -0,0 +1,573 @@ +(function () { + const statusEl = document.getElementById('status'); + const seasonSystemConfigEl = document.getElementById('season-system-config'); + const seasonSystemSaveBtn = document.getElementById('season-system-save-btn'); + const seasonAutoThemeInput = document.getElementById('season-auto-theme'); + const seasonAutoGenerateBtn = document.getElementById('season-auto-generate-btn'); + const seasonCreateBtn = document.getElementById('season-create-btn'); + const seasonCreateName = document.getElementById('season-create-name'); + const seasonCreateTheme = document.getElementById('season-create-theme'); + const seasonCreateStart = document.getElementById('season-create-start'); + const seasonCreateEnd = document.getElementById('season-create-end'); + const seasonCreatePassPrice = document.getElementById('season-create-pass-price'); + const seasonCreateDescription = document.getElementById('season-create-description'); + + const seasonListEl = document.getElementById('season-admin-list'); + const seasonCurrentHintEl = document.getElementById('season-admin-current-hint'); + const seasonRefreshBtn = document.getElementById('season-admin-refresh-btn'); + + const announceTitleInput = document.getElementById('season-announce-title'); + const announceContentInput = document.getElementById('season-announce-content'); + const announceBtn = document.getElementById('season-announce-btn'); + + const taskTitleInput = document.getElementById('season-task-title'); + const taskTypeInput = document.getElementById('season-task-type'); + const taskEventInput = document.getElementById('season-task-event'); + const taskTargetInput = document.getElementById('season-task-target'); + const taskRewardPointsInput = document.getElementById('season-task-reward-points'); + const taskRewardXpInput = document.getElementById('season-task-reward-xp'); + const taskDescInput = document.getElementById('season-task-description'); + const taskSaveBtn = document.getElementById('season-task-save-btn'); + const taskListEl = document.getElementById('season-task-list'); + + const poolTitleInput = document.getElementById('season-pool-title'); + const poolTypeInput = document.getElementById('season-pool-type'); + const poolEventInput = document.getElementById('season-pool-event'); + const poolTargetInput = document.getElementById('season-pool-target'); + const poolRarityInput = document.getElementById('season-pool-rarity'); + const poolWeightInput = document.getElementById('season-pool-weight'); + const poolRewardPointsInput = document.getElementById('season-pool-reward-points'); + const poolRewardXpInput = document.getElementById('season-pool-reward-xp'); + const poolHookInput = document.getElementById('season-pool-hook'); + const poolObjectiveInput = document.getElementById('season-pool-objective'); + const poolHintInput = document.getElementById('season-pool-hint'); + const poolSaveBtn = document.getElementById('season-pool-save-btn'); + const poolListEl = document.getElementById('season-pool-list'); + + const rewardLevelInput = document.getElementById('season-reward-level'); + const rewardTrackInput = document.getElementById('season-reward-track'); + const rewardTitleInput = document.getElementById('season-reward-title'); + const rewardPointsInput = document.getElementById('season-reward-points'); + const rewardXpInput = document.getElementById('season-reward-xp'); + const rewardCoinInput = document.getElementById('season-reward-coin'); + const rewardPayloadInput = document.getElementById('season-reward-payload'); + const rewardSaveBtn = document.getElementById('season-reward-save-btn'); + const rewardListEl = document.getElementById('season-reward-list'); + const seasonInfoName = document.getElementById('season-info-name'); + const seasonInfoTheme = document.getElementById('season-info-theme'); + const seasonInfoStart = document.getElementById('season-info-start'); + const seasonInfoEnd = document.getElementById('season-info-end'); + const seasonInfoRestEnd = document.getElementById('season-info-rest-end'); + const seasonInfoRestDays = document.getElementById('season-info-rest-days'); + const seasonInfoPassPrice = document.getElementById('season-info-pass-price'); + const seasonInfoCover = document.getElementById('season-info-cover'); + const seasonInfoAccent = document.getElementById('season-info-accent'); + const seasonInfoDescription = document.getElementById('season-info-description'); + const seasonInfoSeed = document.getElementById('season-info-seed'); + const seasonInfoOutline = document.getElementById('season-info-outline'); + const seasonInfoSaveBtn = document.getElementById('season-info-save-btn'); + const seasonInfoRestBtn = document.getElementById('season-info-rest-btn'); + const seasonInfoResumeBtn = document.getElementById('season-info-resume-btn'); + const seasonInfoEndBtn = document.getElementById('season-info-end-btn'); + const aiDailyEnabledInput = document.getElementById('season-ai-daily-enabled'); + const aiDailyToneInput = document.getElementById('season-ai-daily-tone'); + const aiDailyDifficultyInput = document.getElementById('season-ai-daily-difficulty'); + const aiDailyExtraInput = document.getElementById('season-ai-daily-extra'); + const aiDailySaveBtn = document.getElementById('season-ai-daily-save-btn'); + + let selectedSeasonId = ''; + let seasonSystemConfig = {}; + let selectedSeasonConfig = {}; + let selectedSeasonMeta = {}; + + const setStatus = (text) => { + if (statusEl) statusEl.textContent = text || ''; + }; + + const getJson = async (url) => { + const res = await fetch(url); + return res.json(); + }; + + const postJson = async (url, payload = {}) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); + }; + + const patchJson = async (url, payload = {}) => { + const res = await fetch(url, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); + }; + + const renderSystemConfig = () => { + if (!seasonSystemConfigEl) return; + seasonSystemConfigEl.textContent = ''; + const rows = [ + `enabled=${Boolean(seasonSystemConfig.enabled)}`, + `auto_create_enabled=${Boolean(seasonSystemConfig.auto_create_enabled)}`, + `auto_generate_theme=${Boolean(seasonSystemConfig.auto_generate_theme)}`, + `season_duration_days=${Number(seasonSystemConfig.season_duration_days || 30)}`, + `daily_card_count=${Number(seasonSystemConfig.daily_card_count || 3)}`, + `refresh_hours=${Array.isArray(seasonSystemConfig.refresh_hours) ? seasonSystemConfig.refresh_hours.join(',') : '0,8,18'}`, + `manual_refresh_cost=${Number(seasonSystemConfig.manual_refresh_cost || 60)}`, + `premium_pass_price=${Number(seasonSystemConfig.premium_pass_price || 680)}`, + ]; + rows.forEach((text) => { + const div = document.createElement('div'); + div.textContent = text; + seasonSystemConfigEl.appendChild(div); + }); + }; + + const loadSystemConfig = async () => { + const data = await getJson('/api/admin/settings/season-system'); + if (!data.ok) { + setStatus(data.error || '加载赛季系统配置失败'); + return; + } + seasonSystemConfig = data.config || {}; + renderSystemConfig(); + }; + + const saveSystemConfig = async () => { + const duration = prompt('赛季默认时长(天)', String(seasonSystemConfig.season_duration_days || 30)); + if (duration === null) return; + const dailyCards = prompt('每日卡片数量', String(seasonSystemConfig.daily_card_count || 3)); + if (dailyCards === null) return; + const refreshHours = prompt('刷新时段(逗号分隔)', (seasonSystemConfig.refresh_hours || [0, 8, 18]).join(',')); + if (refreshHours === null) return; + const manualCost = prompt('手动刷新消耗积分', String(seasonSystemConfig.manual_refresh_cost || 60)); + if (manualCost === null) return; + const passPrice = prompt('高级通行证价格', String(seasonSystemConfig.premium_pass_price || 680)); + if (passPrice === null) return; + + const payload = { + season_duration_days: Number(duration) || 30, + daily_card_count: Number(dailyCards) || 3, + refresh_hours: String(refreshHours).split(',').map((v) => Number(v.trim())).filter((v) => Number.isFinite(v)), + manual_refresh_cost: Number(manualCost) || 60, + premium_pass_price: Number(passPrice) || 680, + enabled: Boolean(seasonSystemConfig.enabled), + auto_create_enabled: Boolean(seasonSystemConfig.auto_create_enabled), + auto_generate_theme: Boolean(seasonSystemConfig.auto_generate_theme), + }; + const data = await patchJson('/api/admin/settings/season-system', payload); + if (!data.ok) { + setStatus(data.error || '保存赛季系统配置失败'); + return; + } + seasonSystemConfig = data.config || {}; + renderSystemConfig(); + setStatus('赛季系统配置已更新。'); + }; + + const renderSeasonList = (rows, active) => { + if (!seasonListEl) return; + seasonListEl.textContent = ''; + const list = Array.isArray(rows) ? rows : []; + if (!list.length) { + const li = document.createElement('li'); + li.textContent = '暂无赛季'; + seasonListEl.appendChild(li); + return; + } + list.forEach((row) => { + const seasonId = String(row.season_id || ''); + const li = document.createElement('li'); + const activeMark = active && String(active.season_id || '') === seasonId ? '(进行中)' : ''; + li.textContent = `${row.name || seasonId} ${activeMark} · ${row.time_status || row.status || '-'} · ${row.start_at || '-'} ~ ${row.end_at || '-'}`; + const selectBtn = document.createElement('button'); + selectBtn.type = 'button'; + selectBtn.className = 'ghost-btn'; + selectBtn.textContent = selectedSeasonId === seasonId ? '已选中' : '选中'; + selectBtn.addEventListener('click', async () => { + selectedSeasonId = seasonId; + await loadSelectedSeason(); + await loadSeasons(); + }); + li.appendChild(selectBtn); + seasonListEl.appendChild(li); + }); + }; + + const renderSimpleList = (el, rows, formatFn) => { + if (!el) return; + el.textContent = ''; + const list = Array.isArray(rows) ? rows : []; + if (!list.length) { + const li = document.createElement('li'); + li.textContent = '暂无数据'; + el.appendChild(li); + return; + } + list.forEach((row) => { + const li = document.createElement('li'); + li.textContent = formatFn(row); + el.appendChild(li); + }); + }; + + const loadSeasons = async () => { + const data = await getJson('/api/admin/seasons'); + if (!data.ok) { + setStatus(data.error || '加载赛季列表失败'); + return; + } + if (!selectedSeasonId && data.active && data.active.season_id) { + selectedSeasonId = String(data.active.season_id); + } + renderSeasonList(data.seasons || [], data.active || null); + }; + + const loadSelectedSeason = async () => { + if (!selectedSeasonId) { + if (seasonCurrentHintEl) seasonCurrentHintEl.textContent = '请选择一个赛季后再配置。'; + renderSimpleList(taskListEl, [], () => ''); + renderSimpleList(poolListEl, [], () => ''); + renderSimpleList(rewardListEl, [], () => ''); + return; + } + if (seasonCurrentHintEl) seasonCurrentHintEl.textContent = `当前选中赛季:${selectedSeasonId}`; + const [dashboardRes, tasksRes, poolsRes, rewardsRes] = await Promise.all([ + getJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}/dashboard`), + getJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}/tasks`), + getJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}/card-pools`), + getJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}/pass-rewards`), + ]); + if (!dashboardRes.ok) { + setStatus(dashboardRes.error || '加载赛季看板失败'); + return; + } + selectedSeasonMeta = dashboardRes.season || {}; + selectedSeasonConfig = (dashboardRes.season || {}).config || {}; + if (aiDailyEnabledInput) aiDailyEnabledInput.checked = Boolean(selectedSeasonConfig.ai_daily_card_enabled); + if (aiDailyToneInput) aiDailyToneInput.value = selectedSeasonConfig.ai_daily_card_tone || ''; + if (aiDailyDifficultyInput) aiDailyDifficultyInput.value = selectedSeasonConfig.ai_daily_card_difficulty || ''; + if (aiDailyExtraInput) aiDailyExtraInput.value = selectedSeasonConfig.ai_daily_card_prompt_extra || ''; + const dashboard = dashboardRes.dashboard || {}; + setStatus(`参与 ${Number(dashboard.participants || 0)},高级档 ${Number(dashboard.premium_users || 0)},转化 ${Number(dashboard.premium_conversion || 0)}%`); + if (seasonInfoName) seasonInfoName.value = selectedSeasonMeta.name || ''; + if (seasonInfoTheme) seasonInfoTheme.value = selectedSeasonMeta.theme || ''; + if (seasonInfoStart) seasonInfoStart.value = selectedSeasonMeta.start_at || ''; + if (seasonInfoEnd) seasonInfoEnd.value = selectedSeasonMeta.end_at || ''; + if (seasonInfoRestEnd) seasonInfoRestEnd.value = selectedSeasonMeta.rest_end_at || ''; + if (seasonInfoRestDays) seasonInfoRestDays.value = Number(selectedSeasonConfig.rest_days || 0); + if (seasonInfoPassPrice) seasonInfoPassPrice.value = Number(selectedSeasonMeta.pass_price || 0); + if (seasonInfoCover) seasonInfoCover.value = selectedSeasonMeta.cover_url || ''; + if (seasonInfoAccent) seasonInfoAccent.value = selectedSeasonMeta.accent_color || ''; + if (seasonInfoDescription) seasonInfoDescription.value = selectedSeasonMeta.description || ''; + if (seasonInfoSeed) seasonInfoSeed.value = selectedSeasonMeta.story_seed || ''; + if (seasonInfoOutline) seasonInfoOutline.value = selectedSeasonMeta.story_outline || ''; + + renderSimpleList( + taskListEl, + tasksRes.ok ? tasksRes.task_templates || [] : [], + (row) => `[${row.task_type || '-'}] ${row.title || '-'} · 事件 ${row.event_key || '-'} · 目标 ${Number(row.target_value || 1)} · 奖励 ${Number(row.reward_points || 0)} 积分` + ); + renderSimpleList( + poolListEl, + poolsRes.ok ? poolsRes.card_pools || [] : [], + (row) => `[${row.card_type || '-'}] ${row.title_hint || '-'} · 事件 ${row.event_key || '-'} · 稀有度 ${row.rarity || 'normal'}` + ); + renderSimpleList( + rewardListEl, + rewardsRes.ok ? rewardsRes.pass_rewards || [] : [], + (row) => `[${row.reward_track || '-'}] Lv.${Number(row.reward_level || 1)} ${row.title || '-'} · ${Number(row.reward_points || 0)} 积分` + ); + }; + + const saveAiDailyConfig = async () => { + if (!selectedSeasonId) { + setStatus('请先选择赛季。'); + return; + } + const payload = { + config: { + ai_daily_card_enabled: Boolean(aiDailyEnabledInput && aiDailyEnabledInput.checked), + ai_daily_card_tone: String(aiDailyToneInput?.value || '').trim(), + ai_daily_card_difficulty: String(aiDailyDifficultyInput?.value || '').trim(), + ai_daily_card_prompt_extra: String(aiDailyExtraInput?.value || '').trim(), + }, + }; + const data = await patchJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}`, payload); + if (!data.ok) { + setStatus(data.error || '保存AI日卡配置失败'); + return; + } + setStatus('AI日卡配置已保存。'); + await loadSelectedSeason(); + }; + + const saveSeasonInfo = async () => { + if (!selectedSeasonId) { + setStatus('请先选择赛季。'); + return; + } + const payload = { + name: String(seasonInfoName?.value || '').trim(), + theme: String(seasonInfoTheme?.value || '').trim(), + start_at: String(seasonInfoStart?.value || '').trim(), + end_at: String(seasonInfoEnd?.value || '').trim(), + rest_end_at: String(seasonInfoRestEnd?.value || '').trim(), + pass_price: Number(seasonInfoPassPrice?.value || 0), + cover_url: String(seasonInfoCover?.value || '').trim(), + accent_color: String(seasonInfoAccent?.value || '').trim(), + description: String(seasonInfoDescription?.value || '').trim(), + story_seed: String(seasonInfoSeed?.value || '').trim(), + story_outline: String(seasonInfoOutline?.value || '').trim(), + config: { + rest_days: Number(seasonInfoRestDays?.value || 0), + }, + }; + const data = await patchJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}`, payload); + if (!data.ok) { + setStatus(data.error || '保存赛季信息失败'); + return; + } + setStatus('赛季信息已保存。'); + await loadSelectedSeason(); + }; + + const endSeason = async () => { + if (!selectedSeasonId) { + setStatus('请先选择赛季。'); + return; + } + if (!confirm('确认手动结束该赛季?')) return; + const nowIso = new Date().toISOString(); + const payload = { status: 'ended', end_at: nowIso }; + const data = await patchJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}`, payload); + if (!data.ok) { + setStatus(data.error || '结束赛季失败'); + return; + } + setStatus('赛季已结束。'); + await loadSeasons(); + await loadSelectedSeason(); + }; + + const enterRestSeason = async () => { + if (!selectedSeasonId) { + setStatus('请先选择赛季。'); + return; + } + if (!confirm('确认将赛季进入休整期?')) return; + const payload = { status: 'rest' }; + const data = await patchJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}`, payload); + if (!data.ok) { + setStatus(data.error || '进入休整期失败'); + return; + } + setStatus('赛季已进入休整期。'); + await loadSeasons(); + await loadSelectedSeason(); + }; + + const resumeSeason = async () => { + if (!selectedSeasonId) { + setStatus('请先选择赛季。'); + return; + } + if (!confirm('确认重新开启该赛季?')) return; + const payload = { status: 'active' }; + const data = await patchJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}`, payload); + if (!data.ok) { + setStatus(data.error || '重新开启赛季失败'); + return; + } + setStatus('赛季已重新开启。'); + await loadSeasons(); + await loadSelectedSeason(); + }; + + const createSeason = async () => { + const payload = { + name: String(seasonCreateName?.value || '').trim(), + theme: String(seasonCreateTheme?.value || '').trim(), + start_at: String(seasonCreateStart?.value || '').trim(), + end_at: String(seasonCreateEnd?.value || '').trim(), + pass_price: Number(seasonCreatePassPrice?.value || 680), + description: String(seasonCreateDescription?.value || '').trim(), + status: 'active', + source: 'manual', + config: { + daily_card_count: 3, + daily_task_count: 3, + weekly_task_count: 3, + refresh_hours: [0, 8, 18], + manual_refresh_cost: 60, + ai_daily_card_enabled: false, + ai_daily_card_tone: '悬疑冒险', + ai_daily_card_difficulty: '中等', + }, + }; + if (!payload.name) { + setStatus('请填写赛季名称。'); + return; + } + const data = await postJson('/api/admin/seasons', payload); + if (!data.ok) { + setStatus(data.error || '创建赛季失败'); + return; + } + selectedSeasonId = String((data.season || {}).season_id || ''); + setStatus(`赛季已创建:${(data.season || {}).name || selectedSeasonId}`); + await loadSeasons(); + await loadSelectedSeason(); + }; + + const autoGenerateSeason = async () => { + const themeHint = String(seasonAutoThemeInput?.value || '').trim() || '迷雾电波'; + const data = await postJson('/api/admin/seasons/auto-generate', { theme_hint: themeHint }); + if (!data.ok) { + setStatus(data.error || '自动生成赛季失败'); + return; + } + selectedSeasonId = String((data.season || {}).season_id || ''); + setStatus(`已自动生成赛季:${(data.season || {}).name || selectedSeasonId}`); + await loadSeasons(); + await loadSelectedSeason(); + }; + + const publishAnnouncement = async () => { + if (!selectedSeasonId) { + setStatus('请先选择赛季。'); + return; + } + const payload = { + title: String(announceTitleInput?.value || '').trim(), + content: String(announceContentInput?.value || '').trim(), + }; + if (!payload.title) { + setStatus('请输入公告标题。'); + return; + } + const data = await postJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}/announce`, payload); + if (!data.ok) { + setStatus(data.error || '发布公告失败'); + return; + } + setStatus('赛季公告已发布。'); + if (announceTitleInput) announceTitleInput.value = ''; + if (announceContentInput) announceContentInput.value = ''; + }; + + const saveTaskTemplate = async () => { + if (!selectedSeasonId) { + setStatus('请先选择赛季。'); + return; + } + const payload = { + title: String(taskTitleInput?.value || '').trim(), + task_type: String(taskTypeInput?.value || '').trim() || 'daily', + event_key: String(taskEventInput?.value || '').trim() || 'complete_game', + target_value: Number(taskTargetInput?.value || 1), + reward_points: Number(taskRewardPointsInput?.value || 0), + reward_xp: Number(taskRewardXpInput?.value || 0), + description: String(taskDescInput?.value || '').trim(), + is_active: true, + }; + if (!payload.title) { + setStatus('请输入任务标题。'); + return; + } + const data = await postJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}/tasks`, payload); + if (!data.ok) { + setStatus(data.error || '保存任务模板失败'); + return; + } + setStatus('任务模板已保存。'); + await loadSelectedSeason(); + }; + + const saveCardPool = async () => { + if (!selectedSeasonId) { + setStatus('请先选择赛季。'); + return; + } + const payload = { + title_hint: String(poolTitleInput?.value || '').trim(), + card_type: String(poolTypeInput?.value || '').trim() || 'rotating', + event_key: String(poolEventInput?.value || '').trim() || 'complete_game', + target_value: Number(poolTargetInput?.value || 1), + rarity: String(poolRarityInput?.value || '').trim() || 'normal', + weight: Number(poolWeightInput?.value || 100), + reward_points: Number(poolRewardPointsInput?.value || 0), + reward_xp: Number(poolRewardXpInput?.value || 0), + story_hook: String(poolHookInput?.value || '').trim(), + objective_template: String(poolObjectiveInput?.value || '').trim(), + hint_template: String(poolHintInput?.value || '').trim(), + is_active: true, + }; + if (!payload.title_hint) { + setStatus('请输入卡池标题提示。'); + return; + } + const data = await postJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}/card-pools`, payload); + if (!data.ok) { + setStatus(data.error || '保存卡池失败'); + return; + } + setStatus('活动卡池模板已保存。'); + await loadSelectedSeason(); + }; + + const savePassReward = async () => { + if (!selectedSeasonId) { + setStatus('请先选择赛季。'); + return; + } + let rewardPayload = {}; + const payloadText = String(rewardPayloadInput?.value || '').trim(); + if (payloadText) { + try { + rewardPayload = JSON.parse(payloadText); + } catch { + setStatus('奖励 JSON 格式无效。'); + return; + } + } + const payload = { + reward_level: Number(rewardLevelInput?.value || 1), + reward_track: String(rewardTrackInput?.value || '').trim() || 'free', + title: String(rewardTitleInput?.value || '').trim(), + reward_points: Number(rewardPointsInput?.value || 0), + reward_xp: Number(rewardXpInput?.value || 0), + reward_season_coin: Number(rewardCoinInput?.value || 0), + reward_payload: rewardPayload, + }; + const data = await postJson(`/api/admin/seasons/${encodeURIComponent(selectedSeasonId)}/pass-rewards`, payload); + if (!data.ok) { + setStatus(data.error || '保存通行证奖励失败'); + return; + } + setStatus('通行证奖励已保存。'); + await loadSelectedSeason(); + }; + + if (seasonSystemSaveBtn) seasonSystemSaveBtn.addEventListener('click', () => saveSystemConfig().catch((e) => setStatus(`网络错误: ${e}`))); + if (seasonAutoGenerateBtn) seasonAutoGenerateBtn.addEventListener('click', () => autoGenerateSeason().catch((e) => setStatus(`网络错误: ${e}`))); + if (seasonCreateBtn) seasonCreateBtn.addEventListener('click', () => createSeason().catch((e) => setStatus(`网络错误: ${e}`))); + if (announceBtn) announceBtn.addEventListener('click', () => publishAnnouncement().catch((e) => setStatus(`网络错误: ${e}`))); + if (taskSaveBtn) taskSaveBtn.addEventListener('click', () => saveTaskTemplate().catch((e) => setStatus(`网络错误: ${e}`))); + if (poolSaveBtn) poolSaveBtn.addEventListener('click', () => saveCardPool().catch((e) => setStatus(`网络错误: ${e}`))); + if (rewardSaveBtn) rewardSaveBtn.addEventListener('click', () => savePassReward().catch((e) => setStatus(`网络错误: ${e}`))); + if (seasonRefreshBtn) seasonRefreshBtn.addEventListener('click', () => loadSelectedSeason().catch((e) => setStatus(`网络错误: ${e}`))); + if (aiDailySaveBtn) aiDailySaveBtn.addEventListener('click', () => saveAiDailyConfig().catch((e) => setStatus(`网络错误: ${e}`))); + if (seasonInfoSaveBtn) seasonInfoSaveBtn.addEventListener('click', () => saveSeasonInfo().catch((e) => setStatus(`网络错误: ${e}`))); + if (seasonInfoRestBtn) seasonInfoRestBtn.addEventListener('click', () => enterRestSeason().catch((e) => setStatus(`网络错误: ${e}`))); + if (seasonInfoResumeBtn) seasonInfoResumeBtn.addEventListener('click', () => resumeSeason().catch((e) => setStatus(`网络错误: ${e}`))); + if (seasonInfoEndBtn) seasonInfoEndBtn.addEventListener('click', () => endSeason().catch((e) => setStatus(`网络错误: ${e}`))); + + Promise.all([loadSystemConfig(), loadSeasons()]) + .then(() => loadSelectedSeason()) + .catch((error) => setStatus(`网络错误: ${error}`)); +})(); diff --git a/static/js/season_cards.js b/static/js/season_cards.js new file mode 100644 index 0000000..d1accc0 --- /dev/null +++ b/static/js/season_cards.js @@ -0,0 +1,127 @@ +(function () { + const body = document.body; + const seasonId = String(body.dataset.seasonId || '').trim(); + + const titleEl = document.getElementById('season-cards-title'); + const subEl = document.getElementById('season-cards-sub'); + const gridEl = document.getElementById('season-cards-grid'); + const statusEl = document.getElementById('status'); + + const setStatus = (text) => { + if (statusEl) statusEl.textContent = text || ''; + }; + + const getJson = async (url) => { + const res = await fetch(url); + return res.json(); + }; + + const postJson = async (url, payload = {}) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); + }; + + const safeCoverUrl = (url) => { + const value = String(url || '').trim(); + if (!value) return ''; + return value.replace(/["\\]/g, '\\$&'); + }; + + const renderEmpty = (text) => { + if (!gridEl) return; + gridEl.textContent = ''; + const div = document.createElement('div'); + div.className = 'helper-text'; + div.textContent = text; + gridEl.appendChild(div); + }; + + const createRoomFromCard = async (cardId) => { + const safeId = String(cardId || '').trim(); + if (!safeId) return; + setStatus('正在创建房间...'); + const data = await postJson('/api/rooms/create', { card_id: safeId }); + if (!data.ok) { + setStatus(data.error || '创建失败'); + return; + } + if (data.player_id) localStorage.setItem(`echo-player-${data.room_id}`, data.player_id); + if (data.player_name) localStorage.setItem('echo-last-name', data.player_name); + window.location.href = data.redirect; + }; + + const renderCards = (cards) => { + if (!gridEl) return; + gridEl.textContent = ''; + const list = Array.isArray(cards) ? cards : []; + if (!list.length) { + renderEmpty('本赛季暂无游戏卡(先生成赛季日卡或等待解锁)。'); + return; + } + list.forEach((card) => { + const article = document.createElement('article'); + article.className = 'game-card'; + + const cover = document.createElement('div'); + cover.className = 'game-card-cover'; + cover.style.backgroundImage = `url("${safeCoverUrl(card.cover_url)}")`; + + const panel = document.createElement('div'); + panel.className = 'game-card-body'; + + const h3 = document.createElement('h3'); + h3.textContent = card.title || '未命名赛季游戏卡'; + panel.appendChild(h3); + + const sub = document.createElement('p'); + sub.className = 'game-card-sub'; + sub.textContent = card.subtitle || '赛季专属'; + panel.appendChild(sub); + + const summary = document.createElement('p'); + summary.textContent = card.summary || ''; + panel.appendChild(summary); + + const actions = document.createElement('div'); + actions.className = 'card-actions-row'; + + const startBtn = document.createElement('button'); + startBtn.type = 'button'; + startBtn.textContent = '用此卡开一局'; + startBtn.addEventListener('click', () => { + createRoomFromCard(card.card_id).catch((error) => setStatus(`网络错误: ${error}`)); + }); + actions.appendChild(startBtn); + + panel.appendChild(actions); + article.appendChild(cover); + article.appendChild(panel); + gridEl.appendChild(article); + }); + }; + + const load = async () => { + if (!seasonId) { + renderEmpty('赛季参数缺失。'); + return; + } + const data = await getJson(`/api/seasons/${encodeURIComponent(seasonId)}/cards`); + if (!data.ok) { + setStatus(data.error || '加载失败'); + renderEmpty('加载失败'); + return; + } + const season = data.season || {}; + const head = `${season.name || '赛季'} · ${season.theme || '未命名主题'}`; + if (titleEl) titleEl.textContent = '赛季游戏卡'; + if (subEl) subEl.textContent = head; + renderCards(data.cards || []); + }; + + load().catch((error) => setStatus(`网络错误: ${error}`)); +})(); + diff --git a/static/js/season_chronicle.js b/static/js/season_chronicle.js new file mode 100644 index 0000000..0f1f213 --- /dev/null +++ b/static/js/season_chronicle.js @@ -0,0 +1,97 @@ +(function () { + const body = document.body; + const subtitleEl = document.getElementById('chronicle-subtitle'); + const scrollEl = document.getElementById('chronicle-scroll'); + const statusEl = document.getElementById('status'); + const seasonId = String(body.dataset.seasonId || '').trim(); + + const setStatus = (text) => { + if (statusEl) statusEl.textContent = text || ''; + }; + + const getJson = async (url) => { + const res = await fetch(url); + return res.json(); + }; + + const renderEmpty = (text) => { + if (!scrollEl) return; + scrollEl.textContent = ''; + const div = document.createElement('div'); + div.className = 'helper-text'; + div.textContent = text; + scrollEl.appendChild(div); + }; + + const renderChronicle = (payload) => { + if (!scrollEl) return; + scrollEl.textContent = ''; + const season = payload.season || {}; + const items = Array.isArray(payload.items) ? payload.items : []; + const title = `${season.name || '赛季'} · ${season.theme || '未命名主题'}`; + if (subtitleEl) subtitleEl.textContent = title; + if (!items.length) { + renderEmpty('暂无编年史内容,先在赛季中心解锁资料。'); + return; + } + + items.forEach((row) => { + const block = document.createElement('section'); + block.className = 'card'; + block.style.margin = '0.8rem 0'; + + const head = document.createElement('div'); + head.textContent = `${row.day_key || '-'} · ${row.title || '赛季日卡'}${row.is_today ? '(今日)' : ''}`; + block.appendChild(head); + + const guide = String(row.guide_text || '').trim(); + if (guide) { + const p = document.createElement('p'); + p.className = 'helper-text'; + p.textContent = guide; + block.appendChild(p); + } + + const intro = String(row.intro_text || '').trim(); + if (intro) { + const p = document.createElement('p'); + p.className = 'helper-text'; + p.textContent = intro; + block.appendChild(p); + } + + const intel = Array.isArray(row.intel) ? row.intel : []; + const ul = document.createElement('ul'); + ul.className = 'compact-list'; + if (!intel.length) { + const li = document.createElement('li'); + li.textContent = '(当日尚未解锁资料)'; + ul.appendChild(li); + } else { + intel.slice(0, 80).forEach((it) => { + const li = document.createElement('li'); + li.textContent = `${it.intel_title || '资料'}:${it.intel_text || ''}`; + ul.appendChild(li); + }); + } + block.appendChild(ul); + + scrollEl.appendChild(block); + }); + }; + + const load = async () => { + const url = seasonId + ? `/api/seasons/${encodeURIComponent(seasonId)}/chronicle` + : '/api/seasons/active/chronicle'; + const data = await getJson(url); + if (!data.ok) { + setStatus(data.error || '加载失败'); + renderEmpty('加载失败'); + return; + } + renderChronicle(data); + }; + + load().catch((error) => setStatus(`网络错误: ${error}`)); +})(); diff --git a/templates/admin.html b/templates/admin.html index 19f89fc..e30125e 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -21,6 +21,7 @@ {{ user.username }}(管理员) + 赛季后台 返回大厅 @@ -37,6 +38,7 @@ 订单管理 积分管理 财务统计 + 赛季后台 OAuth 配置 diff --git a/templates/index.html b/templates/index.html index 5ba9acf..0c15679 100644 --- a/templates/index.html +++ b/templates/index.html @@ -24,6 +24,7 @@ 我的成就 积分中心 积分商城 + 赛季中心 联机大厅 玩家排行 游戏卡广场 diff --git a/templates/plaza.html b/templates/plaza.html index df566a3..c298f78 100644 --- a/templates/plaza.html +++ b/templates/plaza.html @@ -21,6 +21,7 @@ 我的成就 积分中心 积分商城 + 赛季中心 {% endif %} 玩家排行 联机大厅 diff --git a/templates/room_summary.html b/templates/room_summary.html index 10aa3b6..54589ce 100644 --- a/templates/room_summary.html +++ b/templates/room_summary.html @@ -21,6 +21,7 @@ 我的成就 积分中心 积分商城 + 赛季中心 {% endif %} 返回房间 联机大厅 @@ -34,6 +35,8 @@

MVP

+

赛季进度

+
diff --git a/templates/season.html b/templates/season.html new file mode 100644 index 0000000..18e603e --- /dev/null +++ b/templates/season.html @@ -0,0 +1,123 @@ + + + + + + 赛季中心 - Echo Tavern + + + +
+
+

赛季中心

+

加载赛季信息...

+
+ +
+ +
+
+
+
+
+
+

赛季加载中...

+

加载赛季信息...

+
+
+ + +
+
+
+
+
+
+ +
+

赛季切换

+

选择任意赛季后,可进入该赛季的专属游戏卡列表与编年史(与玩家/全局卡不互通)。

+ +
+ +
+

📖 今日故事

+
+ +
+ +
+

赛季公告

+
    +
    + +
    +

    每日赛季日卡

    +

    每天生成 1 张赛季专属的完整游戏卡。通关后可在“我的资料”解锁当日资料,并结算赛季积分/经验。

    + +
    +
    + +
    +

    我的资料

    +
      +
      + + + +
      +

      活动任务卡片

      +

      +
        +
        + +
        +

        任务总览

        +
        + + + + +
        +
          +
          + +
          +

          通行证奖励

          +
            +
            + +
            +

            装扮展柜

            +
              +
              +
              + +
              + + + + + diff --git a/templates/season_admin.html b/templates/season_admin.html new file mode 100644 index 0000000..55c2627 --- /dev/null +++ b/templates/season_admin.html @@ -0,0 +1,143 @@ + + + + + + 赛季后台 - Echo Tavern + + + +
              +
              +

              赛季后台

              +

              创建赛季、发布公告、配置任务池/卡池/奖励

              +
              + +
              + +
              +
              +

              赛季系统配置

              +
              +
              + +
              +

              AI 自动生成赛季

              +
              + + +
              +

              创建赛季

              +
              + + + + + + + +
              +
              + +
              +

              赛季列表

              +
                +
                + +
                +

                当前赛季配置

                +

                请选择一个赛季后再配置。

                +
                + +
                +

                赛季信息

                +
                + + + + + + + + + + + + +
                + + + + +
                +
                +

                公告

                +
                + + + +
                +

                任务模板

                +
                + + + + + + + + +
                +
                  +

                  活动卡池

                  +
                  + + + + + + + + + + + + +
                  +
                    +

                    通行证奖励

                    +
                    + + + + + + + + +
                    +
                      + +

                      AI 赛季日卡配置

                      +
                      + + + + + +
                      +
                      +
                      + +
                      + + + + + diff --git a/templates/season_cards.html b/templates/season_cards.html new file mode 100644 index 0000000..81cb4d6 --- /dev/null +++ b/templates/season_cards.html @@ -0,0 +1,44 @@ + + + + + + 赛季游戏卡 - Echo Tavern + + + +
                      +
                      +

                      赛季游戏卡

                      +

                      加载赛季信息...

                      +
                      + +
                      + +
                      +
                      +

                      本赛季专属游戏卡

                      +

                      仅显示该赛季生成/归档的游戏卡,与玩家/全局游戏卡不互通。

                      +
                      +
                      +
                      +

                      提示

                      +

                      赛季日卡是完整的游戏卡;每天解锁的资料是该游戏卡的“结局/沉淀”。

                      +

                      在赛季中心推进并解锁资料后,编年史会自动汇总。

                      + 查看赛季编年史 +
                      +
                      + +
                      + + + + + diff --git a/templates/season_chronicle.html b/templates/season_chronicle.html new file mode 100644 index 0000000..573adc1 --- /dev/null +++ b/templates/season_chronicle.html @@ -0,0 +1,36 @@ + + + + + + 赛季编年史 - Echo Tavern + + + +
                      +
                      +

                      赛季编年史

                      +

                      加载赛季编年史...

                      +
                      + +
                      + +
                      +
                      +

                      可滚动赛季编年史

                      +

                      引导语 + 每日卡资料将按时间线沉淀。休整期会在赛季中心置顶展示。

                      +
                      +
                      +
                      + +
                      + + + + diff --git a/templates/user_profile.html b/templates/user_profile.html index 6fe20ca..fc8f678 100644 --- a/templates/user_profile.html +++ b/templates/user_profile.html @@ -21,6 +21,7 @@ 我的成就 积分中心 积分商城 + 赛季中心 {% endif %} 联机大厅 返回排行