committed by
trae-solo-agent
parent
22f5240fa7
commit
08682045d4
@@ -4,15 +4,18 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-kernel"
|
||||
version = "2.0.2b1+1021"
|
||||
version = "2.0.4b1+1023"
|
||||
description = "Stable routing and extension authority for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-config==2.0.2b1+1021",
|
||||
"jwebot-sdk==2.0.2b1+1021",
|
||||
"jwebot-official-extensions==2.0.2b1+1021",
|
||||
"jwebot-config==2.0.4b1+1023",
|
||||
"jwebot-sdk==2.0.4b1+1023",
|
||||
"jwebot-official-extensions==2.0.4b1+1023",
|
||||
"tomli>=2; python_version < '3.11'",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
jwebot_kernel = ["data/*.json"]
|
||||
|
||||
@@ -107,4 +107,4 @@ __all__ = [
|
||||
"verify_installed_release_signature",
|
||||
]
|
||||
|
||||
__version__ = "2.0.2b1+1021"
|
||||
__version__ = "2.0.4b1+1023"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -82,6 +83,7 @@ from jwebot_official_extensions.player_games import (
|
||||
lottery_achievement_lines,
|
||||
next_game_index,
|
||||
normalize_player_account,
|
||||
record_blackjack_result,
|
||||
spend_primogems,
|
||||
)
|
||||
from jwebot_sdk import Event, PlanKind
|
||||
@@ -358,73 +360,8 @@ SCAN_BOOST_PITY_MISS_LIMIT = 2
|
||||
STEAM_GROUP_ROSTER: tuple[dict[str, object], ...] = ()
|
||||
PERSON_ALIAS_MIGRATION_VERSION = "person-aliases-empty"
|
||||
PROFILE_ALIAS_REPAIR_CANONICALS: frozenset[str] = frozenset()
|
||||
TRIVIA_QUESTIONS: tuple[dict[str, object], ...] = ()
|
||||
TRIVIA_FACTS: tuple[tuple[str, str, str, str, tuple[str, str, str], str, int], ...] = (
|
||||
("cs-c4-timer", "反恐精英", "官方竞技规则里,炸弹安放后默认爆炸倒计时是多少秒?", "四十秒", ("三十五秒", "四十五秒", "五十秒"), "反恐精英竞技常规炸弹倒计时是四十秒,很多残局路线都按这个时间算。", 4),
|
||||
("cs-smoke-duration", "反恐精英", "反恐精英2 里烟雾弹完全展开后通常持续多久?", "十八秒", ("十五秒", "二十秒", "二十五秒"), "常规烟雾持续约十八秒,卡回防和爆弹节奏时很关键。", 4),
|
||||
("cs-fire-duration", "反恐精英", "燃烧弹或燃烧瓶火焰通常持续多久?", "七秒", ("五秒", "九秒", "十二秒"), "火焰持续约七秒,拖延拆包或清点都围绕这个窗口。", 4),
|
||||
("cs-mr12-premier", "反恐精英", "反恐精英2 优先模式(Premier)常规半场采用哪种回合制?", "单方最多十二回合", ("单方最多十五回合", "单方最多十回合", "单方最多八回合"), "优先模式常规为十二回合半场,和旧十五回合半场节奏不同。", 3),
|
||||
("cs-defuse-kit", "反恐精英", "带拆弹钳时,完整拆包时间会缩短到多少秒?", "五秒", ("四秒", "六秒", "七秒"), "无钳拆包十秒,有钳拆包五秒,残局听钳声和骗拆都靠这个数。", 3),
|
||||
("cs-loss-bonus", "反恐精英", "连续失败奖励在达到上限后,每名队员单回合基础失败奖励是多少?", "三千四百美元", ("二千九百美元", "三千美元", "三千七百美元"), "失败奖励阶梯最高为三千四百美元,是强起和经济局判断的关键数值。", 5),
|
||||
("apex-shield-cell-time", "顶点英雄", "使用小电池恢复护甲通常需要多久?", "三秒", ("二秒", "五秒", "八秒"), "小电池使用时间是三秒,和大电池五秒、医疗箱八秒节奏不同。", 3),
|
||||
("apex-phoenix-time", "顶点英雄", "凤凰治疗包完整使用时间是多少?", "十秒", ("八秒", "十二秒", "十五秒"), "凤凰治疗包同时回满血和甲,但需要十秒完整读条。", 4),
|
||||
("apex-grenade-stack", "顶点英雄", "当前背包里一个格子通常最多能放几颗手雷?", "一颗", ("两颗", "三颗", "四颗"), "手雷单格一颗会显著影响背包管理,这个改动让投掷物压制成本更高。", 4),
|
||||
("apex-wattson-accelerant", "顶点英雄", "哪位传奇使用终极技能加速剂时有特殊收益,能直接充满终极技能?", "华森", ("命脉", "班加罗尔", "瓦尔基里"), "华森的被动让终极技能加速剂直接充满终极技能。", 5),
|
||||
("apex-controller-console", "顶点英雄", "哪类传奇可以使用地图上的环形控制台读取下一圈信息?", "控制位", ("突击位", "游击位", "支援位"), "职业系统里控制位能读取下一圈信息,和侦察位扫描敌人信息不同。", 4),
|
||||
("apex-support-banner", "顶点英雄", "哪类传奇可以通过复制器制作队友旗帜?", "支援位", ("控制位", "突击位", "游击位"), "支援位能制作队友旗帜,这会改变残局复活路线。", 4),
|
||||
("overwatch-ramattra-pummel", "守望先锋", "拉玛刹进入天罚形态后,普通攻击拳波对屏障的关系更接近哪一种?", "可以穿过屏障打人", ("会被屏障完全挡住", "只能打屏障本体", "会治疗屏障后队友"), "天罚形态的拳波可以穿过屏障命中目标,是打盾后站位的关键细节。", 5),
|
||||
("overwatch-sigma-grasp", "守望先锋", "西格玛动能俘获不能吸收哪类伤害?", "光束类伤害", ("飞行弹道", "火箭弹", "榴弹"), "动能俘获吃弹道,不吃光束和近战类伤害。", 4),
|
||||
("overwatch-dva-matrix", "守望先锋", "D.Va 防御矩阵通常不能拦截哪类攻击?", "近战挥击", ("飞行弹丸", "部分终极弹道", "治疗弹道"), "防御矩阵主要吃弹道,近战、光束等类型不会被矩阵吞掉。", 4),
|
||||
("overwatch-ana-nade", "守望先锋", "安娜生物手雷命中敌人后,最核心的负面效果是什么?", "阻止其受到治疗", ("降低其移动速度", "禁用其换弹", "减少其弹药上限"), "生物手雷的禁疗效果能直接改变团战集火窗口。", 3),
|
||||
("overwatch-lucio-crossfade", "守望先锋", "卢西奥切歌的两个基础光环分别对应什么?", "治疗和加速", ("增伤和护盾", "隐身和侦测", "减伤和吸血"), "卢西奥核心循环就是治疗歌和加速歌的切换。", 2),
|
||||
("overwatch-mei-wall-cancel", "守望先锋", "小美冰墙放出后,玩家再次按技能键通常可以做什么?", "提前取消冰墙", ("让冰墙回血", "让冰墙移动", "把冰墙变成护盾"), "冰墙可以手动提前取消,很多封路和救人操作都靠这个细节。", 3),
|
||||
("lol-control-ward", "英雄联盟", "控制守卫通常不能直接显形哪类状态的英雄?", "真正隐形", ("伪装", "隐形陷阱", "普通守卫"), "控制守卫能看见伪装和陷阱,但不能直接让真正隐形单位显形。", 5),
|
||||
("lol-inhibitor-respawn", "英雄联盟", "召唤师峡谷水晶被摧毁后,通常多久后重生?", "五分钟", ("三分钟", "四分钟", "六分钟"), "水晶重生时间是五分钟,带线和远古资源交换经常围绕它计算。", 4),
|
||||
("lol-turret-plates", "英雄联盟", "防御塔镀层会在游戏时间什么时候脱落?", "十四分钟", ("十分钟", "十二分钟", "十五分钟"), "镀层十四分钟脱落,前期越塔和换线收益会受这个时间点影响。", 3),
|
||||
("lol-baron-spawn", "英雄联盟", "纳什男爵首次刷新时间是?", "二十分钟", ("十五分钟", "十八分钟", "二十五分钟"), "男爵二十分钟刷新,前一波视野和兵线准备通常会提前开始。", 2),
|
||||
("lol-yasuo-wall", "英雄联盟", "亚索风墙主要拦截哪类东西?", "飞行弹道", ("防御塔攻击", "持续光束", "地形效果"), "风墙挡飞行弹道,不是所有远程效果都会被挡。", 4),
|
||||
("lol-gangplank-orange", "英雄联盟", "普朗克橘子的核心功能除回血外还包括什么?", "解除控制", ("刷新火药桶", "获得隐身", "重置大招"), "坏血病疗法能解除控制,是船长对线和团战容错的重要细节。", 3),
|
||||
("gbp-fever", "少女乐团派对", "协力演出里,队伍表现达标后进入的全队加分段通常叫什么?", "狂热时间", ("安可时间", "挑战时间", "应援时间"), "协力演出里的狂热时间会影响整队得分,是多人房节奏点。", 4),
|
||||
("gbp-judgement-order", "少女乐团派对", "演奏判定从最好到较差,紧跟在完美之后的是哪一档?", "很好", ("好", "差", "失误"), "判定顺序里完美之后是很好,再往下才是好、差和失误。", 3),
|
||||
("gbp-live-boost", "少女乐团派对", "通常一次演出最多可以消耗多少个演出能量来提高奖励?", "三个", ("一个", "五个", "十个"), "演出能量影响奖励倍率,单次常规消耗上限是三个。", 4),
|
||||
("gbp-area-item", "少女乐团派对", "练习室、海报等区域道具主要影响什么?", "成员综合力", ("抽卡概率", "歌曲速度", "体力上限"), "区域道具会给乐队或属性提供加成,是配队总综合力的一部分。", 3),
|
||||
("gbp-challenge-live", "少女乐团派对", "挑战演出活动里,进入挑战曲通常要先消耗什么?", "挑战点数", ("星石", "好友点", "练习券"), "挑战演出需要先通过普通演出获取挑战点数,再打挑战曲。", 4),
|
||||
("gbp-special-chart", "少女乐团派对", "特殊难度谱面和专家谱面的关系更接近哪一种?", "另一张独立谱面", ("专家谱自动加速", "专家谱隐藏轨道", "专家谱减少判定"), "特殊难度通常是独立谱面,不只是把专家谱调快。", 4),
|
||||
("kpop-newjeans-attention", "韩流", "新牛仔(NewJeans)出道时最先空降公开的音乐录像是哪首?", "注意", ("炒作男孩", "饼干", "受伤"), "新牛仔(NewJeans)出道营销先公开《注意》的音乐录像,再连续展开其他内容。", 4),
|
||||
("kpop-redvelvet-concept", "韩流", "红贝贝(Red Velvet)的团体概念常被拆成哪两面?", "红与丝绒", ("月与太阳", "甜与苦", "黑与粉"), "红贝贝(Red Velvet)常用红与丝绒两面概念区分明亮和成熟风格。", 3),
|
||||
("kpop-music-show-encore", "韩流", "韩国打歌节目获一位后,现场常见的收尾舞台叫什么?", "安可舞台", ("预录舞台", "回归预告", "签售舞台"), "一位后的安可舞台经常被粉丝拿来观察现场唱功和团体互动。", 3),
|
||||
("kpop-lightstick-name", "韩流", "韩流粉丝应援棒在中文语境里通常被简称为什么?", "应援棒", ("打歌棒", "投票棒", "站位棒"), "应援棒是演唱会和打歌现场常见的视觉应援道具。", 2),
|
||||
("kpop-photocard", "韩流", "专辑小卡随机配置最直接影响粉丝哪种行为?", "拆专收卡", ("直播投票", "现场排队", "舞台换位"), "随机小卡会驱动拆专、交换和收集,是韩流专辑消费的重要机制。", 4),
|
||||
("kpop-fanchant", "韩流", "打歌舞台里粉丝按固定词句一起喊的应援内容通常叫?", "应援词", ("站位表", "预告词", "抽选词"), "应援词会配合歌曲段落出现,是现场文化的一部分。", 3),
|
||||
("genshin-superconduct", "原神", "超导反应最核心的战斗收益是什么?", "降低物理抗性", ("降低水元素抗性", "生成护盾", "恢复元素能量"), "超导会降低敌人物理抗性,主要服务物理伤害打法。", 4),
|
||||
("genshin-dendro-core-limit", "原神", "场上草原核数量超过上限时,通常会怎样?", "较早生成的草原核会提前爆炸", ("所有草原核消失", "刷新持续时间", "变成晶片"), "草原核有场上数量限制,超过后旧核会提前触发爆炸。", 5),
|
||||
("genshin-hyperbloom", "原神", "超绽放由什么元素触发草原核产生?", "雷元素", ("火元素", "冰元素", "风元素"), "雷元素命中草原核会触发超绽放,生成追踪性质的伤害。", 3),
|
||||
("genshin-burgeon", "原神", "烈绽放由什么元素触发草原核产生?", "火元素", ("雷元素", "岩元素", "冰元素"), "火元素命中草原核会触发烈绽放,造成范围草元素伤害。", 3),
|
||||
("genshin-crystallize", "原神", "结晶反应掉落的晶片主要提供什么?", "对应元素护盾", ("元素能量", "暴击率加成", "治疗效果"), "岩元素和可结晶元素反应会产生晶片,拾取后形成对应元素护盾。", 3),
|
||||
("genshin-resin-cap", "原神", "当前原粹树脂自然恢复上限是多少?", "二百", ("一百六十", "一百八十", "二百四十"), "原粹树脂上限后来提高到二百,日常体力规划随之变化。", 4),
|
||||
("lovelive-snow-halation", "缪斯学园偶像", "《雪色光晕》演出中,观众应援色由白转橙通常对应谁的独唱段?", "高坂穗乃果", ("南小鸟", "园田海未", "绚濑绘里"), "白转橙是《雪色光晕》现场文化中非常有名的段落。", 4),
|
||||
("lovelive-token-event", "缪斯学园偶像", "学园偶像祭传统代币活动里,打活动曲通常要消耗什么?", "活动道具", ("友情点", "练习贴纸", "社员证"), "代币活动先刷普通歌曲拿活动道具,再消耗道具打活动曲。", 4),
|
||||
("lovelive-score-match", "缪斯学园偶像", "学园偶像祭的分数赛活动核心更接近哪种玩法?", "多人按分数排名", ("单人剧情解谜", "抽卡保底挑战", "社团领地战"), "分数赛会把玩家匹配到一起,按歌曲得分排位结算。", 4),
|
||||
("lovelive-medley", "缪斯学园偶像", "学园偶像祭的串烧嘉年华一次最多连续打几首歌?", "三首", ("两首", "四首", "五首"), "串烧嘉年华可以选择连续打一到三首歌,体力和稳定性都更吃紧。", 4),
|
||||
("lovelive-bibi", "缪斯学园偶像", "小队碧碧(BiBi)由哪组成员组成?", "绘里、真姬、妮可", ("穗乃果、小鸟、花阳", "海未、凛、希", "小鸟、海未、绘里"), "碧碧(BiBi)成员是绚濑绘里、西木野真姬、矢泽妮可。", 3),
|
||||
("lovelive-printemps", "缪斯学园偶像", "小队春色少女(Printemps)由哪组成员组成?", "穗乃果、小鸟、花阳", ("绘里、真姬、妮可", "海未、凛、希", "穗乃果、凛、妮可"), "春色少女(Printemps)成员是高坂穗乃果、南小鸟、小泉花阳。", 3),
|
||||
("minecraft-beacon-base", "我的世界", "满级信标金字塔如果全部铺满,一共需要多少个方块?", "一百六十四个", ("一百二十八个", "一百四十四个", "一百九十二个"), "满级信标底座为九乘九、七乘七、五乘五、三乘三,合计一百六十四个方块。", 5),
|
||||
("minecraft-bookshelves", "我的世界", "附魔台达到三十级附魔通常最多需要多少个书架参与加成?", "十五个", ("十二个", "十八个", "二十个"), "十五个有效书架即可提供三十级附魔所需的书架加成。", 3),
|
||||
("minecraft-nether-portal-min", "我的世界", "不算四个角,最小下界传送门框架需要多少个黑曜石?", "十个", ("八个", "十二个", "十四个"), "最小传送门外框是四乘五,四角可省,所以最少十个黑曜石。", 4),
|
||||
("minecraft-respawn-anchor", "我的世界", "重生锚在哪个维度用于设定重生点?", "下界", ("主世界", "末地", "深暗之域"), "重生锚用于下界重生,在主世界或末地使用会爆炸。", 4),
|
||||
("minecraft-comparator-composter", "我的世界", "堆肥桶满级时,比较器读取到的红石信号强度是多少?", "八", ("七", "九", "十五"), "堆肥桶从空到满对应零到八级比较器信号。", 5),
|
||||
("minecraft-sponge-nether", "我的世界", "湿海绵放在下界时会发生什么?", "立刻变干", ("变成岩浆", "掉落成物品", "无法放置"), "湿海绵在下界会立即干燥,这是处理湿海绵的冷门方法。", 4),
|
||||
)
|
||||
TRIVIA_TEMPLATES = (
|
||||
"【{domain} 冷知识】{prompt}",
|
||||
"塔菲冷门题:{prompt}",
|
||||
"不查资料猜一下:{prompt}",
|
||||
"这个有点偏:{prompt}",
|
||||
"冷知识选项题:{prompt}",
|
||||
"老玩家小测:{prompt}",
|
||||
"边角料问题:{prompt}",
|
||||
"五分钟抢答:{prompt}",
|
||||
)
|
||||
TRIVIA_DATA_RESOURCE = "data/trivia_questions.json"
|
||||
TRIVIA_TARGET_QUESTION_COUNT = 500
|
||||
TRIVIA_OPTION_LABELS = ("A", "B", "C", "D")
|
||||
|
||||
|
||||
@@ -451,22 +388,66 @@ def supported_trivia_topics_message() -> str:
|
||||
return "支持主题:" + "、".join(TRIVIA_TOPIC_NAMES)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def trivia_data_payload() -> dict[str, object]:
|
||||
resource = resources.files(__package__ or "jwebot_kernel").joinpath(
|
||||
TRIVIA_DATA_RESOURCE
|
||||
)
|
||||
with resource.open("r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("trivia data payload must be a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def trivia_question_bank() -> tuple[dict[str, object], ...]:
|
||||
questions: list[dict[str, object]] = [
|
||||
{
|
||||
**question,
|
||||
"base_id": str(question["id"]),
|
||||
"topic": canonical_trivia_topic(question.get("domain")) or str(question["domain"]),
|
||||
"rarity": int(question.get("rarity", 4)),
|
||||
"reward": trivia_reward(int(question.get("rarity", 4))),
|
||||
}
|
||||
for question in TRIVIA_QUESTIONS
|
||||
]
|
||||
for base_id, domain, prompt, answer, distractors, explanation, rarity in TRIVIA_FACTS:
|
||||
payload = trivia_data_payload()
|
||||
templates = tuple(
|
||||
normalize_text(template)
|
||||
for template in payload.get("templates", [])
|
||||
if normalize_text(template)
|
||||
)
|
||||
facts = payload.get("facts", [])
|
||||
if not templates or not isinstance(facts, list):
|
||||
raise RuntimeError("trivia data requires templates and facts")
|
||||
|
||||
questions: list[dict[str, object]] = []
|
||||
seen_base_ids: set[str] = set()
|
||||
for index, raw in enumerate(facts, start=1):
|
||||
if not isinstance(raw, dict):
|
||||
raise RuntimeError(f"trivia fact #{index} must be an object")
|
||||
base_id = bounded_text(raw.get("id"), limit=80)
|
||||
domain = bounded_text(raw.get("domain"), limit=80)
|
||||
prompt = bounded_text(raw.get("prompt"), limit=300)
|
||||
answer = bounded_text(raw.get("answer"), limit=120)
|
||||
explanation = bounded_text(raw.get("explanation"), limit=500)
|
||||
raw_distractors = raw.get("distractors", [])
|
||||
if (
|
||||
not base_id
|
||||
or not domain
|
||||
or not prompt
|
||||
or not answer
|
||||
or base_id in seen_base_ids
|
||||
or not isinstance(raw_distractors, list)
|
||||
or len(raw_distractors) != 3
|
||||
):
|
||||
raise RuntimeError(f"invalid trivia fact: {base_id or index}")
|
||||
distractors = tuple(
|
||||
bounded_text(item, limit=120) for item in raw_distractors
|
||||
)
|
||||
if len(set((answer, *distractors))) != 4 or any(
|
||||
not item for item in distractors
|
||||
):
|
||||
raise RuntimeError(f"invalid trivia options: {base_id}")
|
||||
seen_base_ids.add(base_id)
|
||||
rarity = max(1, min(5, int(raw.get("rarity", 4))))
|
||||
choices = (answer, *distractors)
|
||||
for variant, template in enumerate(TRIVIA_TEMPLATES):
|
||||
rotated = choices[variant % len(choices) :] + choices[: variant % len(choices)]
|
||||
for variant, template in enumerate(templates):
|
||||
rotated = (
|
||||
choices[variant % len(choices) :]
|
||||
+ choices[: variant % len(choices)]
|
||||
)
|
||||
answer_label = TRIVIA_OPTION_LABELS[rotated.index(answer)]
|
||||
questions.append(
|
||||
{
|
||||
@@ -482,6 +463,11 @@ def trivia_question_bank() -> tuple[dict[str, object], ...]:
|
||||
"reward": trivia_reward(rarity),
|
||||
}
|
||||
)
|
||||
if len(questions) != TRIVIA_TARGET_QUESTION_COUNT:
|
||||
raise RuntimeError(
|
||||
"trivia data expanded to "
|
||||
f"{len(questions)} questions, expected {TRIVIA_TARGET_QUESTION_COUNT}"
|
||||
)
|
||||
return tuple(questions)
|
||||
ALIAS_IDENTITY_QUESTION = re.compile(
|
||||
r"(?:谁(?:啊|呀)?|哪(?:位|个|个人)|什么人|叫(?:什么|啥)|全名)",
|
||||
@@ -878,7 +864,7 @@ def thread_topic_label(route: str, reason: str) -> str:
|
||||
if "lottery" in reason:
|
||||
return "彩票"
|
||||
if "draw" in reason:
|
||||
return "抽奖"
|
||||
return "许愿池"
|
||||
if "steam" in reason:
|
||||
return "Steam 状态"
|
||||
if route == "broadcast":
|
||||
@@ -5563,6 +5549,12 @@ class Ledger:
|
||||
stake=bet,
|
||||
payout=payout,
|
||||
)
|
||||
record_blackjack_result(
|
||||
account,
|
||||
outcome=outcome,
|
||||
stake=bet,
|
||||
payout=payout,
|
||||
)
|
||||
achievements = blackjack_achievement_lines(
|
||||
account,
|
||||
outcome=outcome,
|
||||
@@ -6500,6 +6492,136 @@ class Ledger:
|
||||
"game_counts": dict(account["game_counts"]),
|
||||
}
|
||||
|
||||
def player_blackjack_stats(self, group: str) -> dict[str, Any]:
|
||||
group = bounded_text(group, limit=256)
|
||||
self.seed_person_aliases(group)
|
||||
with self.lock:
|
||||
account_rows = [
|
||||
dict(row)
|
||||
for row in self.connection.execute(
|
||||
"""
|
||||
SELECT person_key, state_json
|
||||
FROM player_accounts
|
||||
WHERE group_fingerprint = ?
|
||||
""",
|
||||
(group,),
|
||||
).fetchall()
|
||||
]
|
||||
alias_rows = [
|
||||
dict(row)
|
||||
for row in self.connection.execute(
|
||||
"""
|
||||
SELECT DISTINCT canonical_name
|
||||
FROM person_aliases
|
||||
WHERE group_fingerprint = ?
|
||||
""",
|
||||
(group,),
|
||||
).fetchall()
|
||||
]
|
||||
profile_rows = [
|
||||
dict(row)
|
||||
for row in self.connection.execute(
|
||||
"""
|
||||
SELECT person_key, display_name
|
||||
FROM person_profiles
|
||||
WHERE group_fingerprint = ?
|
||||
""",
|
||||
(group,),
|
||||
).fetchall()
|
||||
]
|
||||
observation_rows = [
|
||||
dict(row)
|
||||
for row in self.connection.execute(
|
||||
"""
|
||||
SELECT person_key, display_name
|
||||
FROM person_observations
|
||||
WHERE group_fingerprint = ?
|
||||
ORDER BY observed_at DESC, created_at DESC
|
||||
""",
|
||||
(group,),
|
||||
).fetchall()
|
||||
]
|
||||
labels: dict[str, str] = {}
|
||||
for row in alias_rows:
|
||||
canonical = bounded_text(row["canonical_name"], limit=120)
|
||||
if canonical:
|
||||
labels[person_scope_key(group, canonical)] = person_display_label(
|
||||
canonical
|
||||
)
|
||||
for row in profile_rows:
|
||||
labels.setdefault(
|
||||
str(row["person_key"]),
|
||||
person_display_label(row["display_name"]),
|
||||
)
|
||||
for row in observation_rows:
|
||||
labels.setdefault(
|
||||
str(row["person_key"]),
|
||||
person_display_label(row["display_name"]),
|
||||
)
|
||||
|
||||
players: list[dict[str, Any]] = []
|
||||
total_wins = 0
|
||||
total_losses = 0
|
||||
total_ties = 0
|
||||
total_stake = 0
|
||||
total_payout = 0
|
||||
for row in account_rows:
|
||||
person_key = str(row["person_key"])
|
||||
try:
|
||||
account = json.loads(str(row["state_json"]))
|
||||
except json.JSONDecodeError as error:
|
||||
raise LocalCommandError("摩拉账户状态无效") from error
|
||||
if not isinstance(account, dict):
|
||||
raise LocalCommandError("摩拉账户状态无效")
|
||||
normalize_player_account(account)
|
||||
stats = account["blackjack_stats"]
|
||||
hands = int(stats["hands"])
|
||||
if hands <= 0:
|
||||
continue
|
||||
wins = int(stats["wins"])
|
||||
losses = int(stats["losses"])
|
||||
ties = int(stats["ties"])
|
||||
stake = int(stats["stake"])
|
||||
payout = int(stats["payout"])
|
||||
total_wins += wins
|
||||
total_losses += losses
|
||||
total_ties += ties
|
||||
total_stake += stake
|
||||
total_payout += payout
|
||||
players.append(
|
||||
{
|
||||
"display_name": labels.get(
|
||||
person_key,
|
||||
f"玩家 {person_key[:8]}",
|
||||
),
|
||||
"hands": hands,
|
||||
"wins": wins,
|
||||
"losses": losses,
|
||||
"ties": ties,
|
||||
"stake": stake,
|
||||
"payout": payout,
|
||||
"net": payout - stake,
|
||||
}
|
||||
)
|
||||
players.sort(
|
||||
key=lambda item: (
|
||||
-int(item["wins"]) / int(item["hands"]),
|
||||
-int(item["hands"]),
|
||||
str(item["display_name"]).casefold(),
|
||||
)
|
||||
)
|
||||
hands = total_wins + total_losses + total_ties
|
||||
return {
|
||||
"hands": hands,
|
||||
"taffy_wins": total_losses,
|
||||
"taffy_losses": total_wins,
|
||||
"ties": total_ties,
|
||||
"stake": total_stake,
|
||||
"payout": total_payout,
|
||||
"taffy_net": total_stake - total_payout,
|
||||
"players": players,
|
||||
}
|
||||
|
||||
def player_coin_ranking(
|
||||
self,
|
||||
group: str,
|
||||
@@ -7295,6 +7417,83 @@ class Ledger:
|
||||
self.connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def apply_player_local_game_action(
|
||||
self,
|
||||
group: str,
|
||||
sender: str,
|
||||
event_id: str,
|
||||
command: str,
|
||||
game_key: str,
|
||||
action: Callable[[dict[str, Any], dict[str, Any]], str],
|
||||
) -> str:
|
||||
"""Commit one player account and one group-local game state together."""
|
||||
person = self._player_identity(group, sender)
|
||||
person_key = str(person["person_key"])
|
||||
with self.lock:
|
||||
self.connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
replay = self._player_action_replay(
|
||||
group, event_id, person_key, command
|
||||
)
|
||||
if replay is not None:
|
||||
self.connection.execute("COMMIT")
|
||||
return replay
|
||||
account = self._load_player_account(group, person_key)
|
||||
row = self.connection.execute(
|
||||
"""
|
||||
SELECT state_json
|
||||
FROM local_games
|
||||
WHERE group_fingerprint = ? AND game_key = ?
|
||||
""",
|
||||
(group, game_key),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
game_state: dict[str, Any] = {}
|
||||
else:
|
||||
try:
|
||||
raw_state = json.loads(str(row["state_json"]))
|
||||
except json.JSONDecodeError as error:
|
||||
raise LocalCommandError("本地游戏状态无效") from error
|
||||
if not isinstance(raw_state, dict):
|
||||
raise LocalCommandError("本地游戏状态无效")
|
||||
game_state = raw_state
|
||||
response = action(account, game_state)
|
||||
timestamp = utc_now()
|
||||
self._save_player_account(
|
||||
group, person_key, account, timestamp
|
||||
)
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO local_games (
|
||||
group_fingerprint, game_key, state_json,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(group_fingerprint, game_key) DO UPDATE
|
||||
SET state_json = excluded.state_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
group,
|
||||
game_key,
|
||||
canonical_json(game_state),
|
||||
timestamp,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
self._store_player_action(
|
||||
group,
|
||||
event_id,
|
||||
person_key,
|
||||
command,
|
||||
response,
|
||||
timestamp,
|
||||
)
|
||||
self.connection.execute("COMMIT")
|
||||
return response
|
||||
except BaseException:
|
||||
self.connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def _autochess_action_replay(
|
||||
self,
|
||||
group: str,
|
||||
@@ -7770,25 +7969,63 @@ class HourlyBroadcastEngine:
|
||||
message=message,
|
||||
)
|
||||
|
||||
def trivia_recent_base_ids(self, group: str) -> list[str]:
|
||||
def trivia_history_state(self, group: str) -> dict[str, object]:
|
||||
state = self.ledger.local_game(group, TRIVIA_HISTORY_KEY)
|
||||
if not state:
|
||||
return []
|
||||
return {"recent_base_ids": [], "ask_counts": {}}
|
||||
raw = state.get("recent_base_ids", [])
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [bounded_text(item, limit=80) for item in raw if item][-TRIVIA_RECENT_BASE_LIMIT:]
|
||||
recent = (
|
||||
[bounded_text(item, limit=80) for item in raw if item]
|
||||
if isinstance(raw, list)
|
||||
else []
|
||||
)
|
||||
raw_counts = state.get("ask_counts", {})
|
||||
counts: dict[str, int] = {}
|
||||
if isinstance(raw_counts, Mapping):
|
||||
for key, value in raw_counts.items():
|
||||
base_id = bounded_text(key, limit=80)
|
||||
if not base_id:
|
||||
continue
|
||||
try:
|
||||
count = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
counts[base_id] = max(0, count)
|
||||
return {
|
||||
"recent_base_ids": recent[-TRIVIA_RECENT_BASE_LIMIT:],
|
||||
"ask_counts": counts,
|
||||
}
|
||||
|
||||
def trivia_recent_base_ids(self, group: str) -> list[str]:
|
||||
state = self.trivia_history_state(group)
|
||||
raw = state.get("recent_base_ids", [])
|
||||
return list(raw) if isinstance(raw, list) else []
|
||||
|
||||
def trivia_ask_counts(self, group: str) -> dict[str, int]:
|
||||
state = self.trivia_history_state(group)
|
||||
raw = state.get("ask_counts", {})
|
||||
return dict(raw) if isinstance(raw, dict) else {}
|
||||
|
||||
def remember_trivia_question(self, group: str, question: dict[str, object]) -> None:
|
||||
base_id = bounded_text(question.get("base_id") or question.get("id"), limit=80)
|
||||
if not base_id:
|
||||
return
|
||||
recent = [item for item in self.trivia_recent_base_ids(group) if item != base_id]
|
||||
recent = [
|
||||
item
|
||||
for item in self.trivia_recent_base_ids(group)
|
||||
if item != base_id
|
||||
]
|
||||
recent.append(base_id)
|
||||
counts = self.trivia_ask_counts(group)
|
||||
counts[base_id] = counts.get(base_id, 0) + 1
|
||||
self.ledger.save_local_game(
|
||||
group,
|
||||
TRIVIA_HISTORY_KEY,
|
||||
{"recent_base_ids": recent[-TRIVIA_RECENT_BASE_LIMIT:]},
|
||||
{
|
||||
"recent_base_ids": recent[-TRIVIA_RECENT_BASE_LIMIT:],
|
||||
"ask_counts": counts,
|
||||
"updated_at": utc_now(),
|
||||
},
|
||||
)
|
||||
|
||||
def select_trivia_question(
|
||||
@@ -7806,17 +8043,33 @@ class HourlyBroadcastEngine:
|
||||
if not bank:
|
||||
raise LocalCommandError("这个主题还没有题喵\n" + supported_trivia_topics_message())
|
||||
recent = set(self.trivia_recent_base_ids(group))
|
||||
ask_counts = self.trivia_ask_counts(group)
|
||||
seed = (
|
||||
f"{group}|{local_now.date().isoformat()}|"
|
||||
f"{local_now.hour:02d}|trivia|{topic or 'all'}"
|
||||
)
|
||||
start = deterministic_int(seed, 0, len(bank) - 1)
|
||||
for offset in range(len(bank)):
|
||||
candidate = bank[(start + offset) % len(bank)]
|
||||
base_id = str(candidate.get("base_id") or candidate.get("id"))
|
||||
if base_id not in recent:
|
||||
return candidate
|
||||
return bank[start]
|
||||
min_asked = min(
|
||||
ask_counts.get(str(question.get("base_id") or question.get("id")), 0)
|
||||
for question in bank
|
||||
)
|
||||
least_asked = tuple(
|
||||
question
|
||||
for question in bank
|
||||
if ask_counts.get(
|
||||
str(question.get("base_id") or question.get("id")),
|
||||
0,
|
||||
)
|
||||
== min_asked
|
||||
)
|
||||
pool = tuple(
|
||||
question
|
||||
for question in least_asked
|
||||
if str(question.get("base_id") or question.get("id")) not in recent
|
||||
) or least_asked
|
||||
selected = dict(pool[deterministic_int(seed, 0, len(pool) - 1)])
|
||||
base_id = str(selected.get("base_id") or selected.get("id"))
|
||||
selected["ask_count"] = ask_counts.get(base_id, 0) + 1
|
||||
return selected
|
||||
|
||||
@staticmethod
|
||||
def render_trivia_message(question: dict[str, object]) -> str:
|
||||
@@ -7826,11 +8079,18 @@ class HourlyBroadcastEngine:
|
||||
rarity = max(1, min(5, int(question.get("rarity", 3))))
|
||||
reward = max(1, min(500, int(question.get("reward", trivia_reward(rarity)))))
|
||||
topic = normalize_text(question.get("topic") or question.get("domain"))
|
||||
heading = f"冷门知识问答(冷门度 {rarity}/5,限时5分钟,答对 +{reward} 摩拉)"
|
||||
try:
|
||||
ask_count = max(1, int(question.get("ask_count", 1)))
|
||||
except (TypeError, ValueError):
|
||||
ask_count = 1
|
||||
heading = (
|
||||
f"冷门知识问答(冷门度 {rarity}/5,"
|
||||
f"出现次数:{ask_count},限时5分钟,答对 +{reward} 摩拉)"
|
||||
)
|
||||
if topic:
|
||||
heading = (
|
||||
f"冷门知识问答(主题:{topic},冷门度 {rarity}/5,"
|
||||
f"限时5分钟,答对 +{reward} 摩拉)"
|
||||
f"出现次数:{ask_count},限时5分钟,答对 +{reward} 摩拉)"
|
||||
)
|
||||
lines = [
|
||||
heading,
|
||||
@@ -7872,6 +8132,7 @@ class HourlyBroadcastEngine:
|
||||
"explanation": str(question["explanation"]),
|
||||
"rarity": int(question.get("rarity", 3)),
|
||||
"reward": int(question.get("reward", 50)),
|
||||
"ask_count": int(question.get("ask_count", 1)),
|
||||
"created_at": local_now.timestamp(),
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user