Release beta 2.0.4.1023
This commit is contained in:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-config"
|
||||
version = "2.0.2b1+1021"
|
||||
version = "2.0.4b1+1023"
|
||||
description = "Profile-based configuration compiler for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
|
||||
@@ -36,4 +36,4 @@ __all__ = [
|
||||
"write_default_profile",
|
||||
]
|
||||
|
||||
__version__ = "2.0.2b1+1021"
|
||||
__version__ = "2.0.4b1+1023"
|
||||
|
||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-extension-host"
|
||||
version = "2.0.2b1+1021"
|
||||
version = "2.0.4b1+1023"
|
||||
description = "Single-release, single-invocation process host for JWeBot extensions"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==2.0.2b1+1021",
|
||||
"jwebot-kernel==2.0.2b1+1021",
|
||||
"jwebot-sdk==2.0.4b1+1023",
|
||||
"jwebot-kernel==2.0.4b1+1023",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -16,4 +16,4 @@ __all__ = [
|
||||
"process_request",
|
||||
]
|
||||
|
||||
__version__ = "2.0.2b1+1021"
|
||||
__version__ = "2.0.4b1+1023"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-official-extensions"
|
||||
version = "2.0.2b1+1021"
|
||||
version = "2.0.4b1+1023"
|
||||
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==2.0.2b1+1021",
|
||||
"jwebot-sdk==2.0.4b1+1023",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -90,4 +90,4 @@ __all__ = [
|
||||
"render_feature_list",
|
||||
]
|
||||
|
||||
__version__ = "2.0.2b1+1021"
|
||||
__version__ = "2.0.4b1+1023"
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
|
||||
PROFILE = 'single-player-games'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '摩拉游戏'
|
||||
HELP_ENTRIES = ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',)
|
||||
COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
|
||||
HELP_ENTRIES = ('/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助',)
|
||||
COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/许愿池', '/许愿', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
|
||||
ADAPTER = ServiceCommandAdapter(
|
||||
PROFILE,
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
|
||||
@@ -74,6 +74,8 @@ LOCAL_COMMAND_ALIASES = {
|
||||
"borrow": "loan",
|
||||
"还款": "repay",
|
||||
"repay": "repay",
|
||||
"许愿池": "prize_draw",
|
||||
"许愿": "prize_draw",
|
||||
"抽奖": "prize_draw",
|
||||
"draw": "prize_draw",
|
||||
"彩票": "lottery",
|
||||
@@ -177,7 +179,7 @@ LOCAL_EXTENSIONS = (
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="摩拉游戏",
|
||||
help_entries=(
|
||||
"/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助",
|
||||
"/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助",
|
||||
),
|
||||
command_kinds=(
|
||||
"wallet",
|
||||
|
||||
+398
-52
@@ -9,15 +9,21 @@ from typing import Any
|
||||
|
||||
|
||||
INITIAL_COINS = 1000
|
||||
MIN_DRAW_BET = 2
|
||||
MAX_DRAW_BET = 100
|
||||
WISH_POOL_GAME_KEY = "wish_pool"
|
||||
WISH_POOL_UNIT = 1000
|
||||
MIN_WISH_POOL_AMOUNT = 1000
|
||||
MAX_WISH_POOL_AMOUNT = 10_000
|
||||
WISH_POOL_CONTRIBUTION = 200
|
||||
WISH_POOL_PITY_LIMIT = 8
|
||||
WISH_POOL_JACKPOT_PERCENT = 20
|
||||
WISH_POOL_JACKPOT_CAP = 20_000
|
||||
MIN_LOTTERY_BET = 1
|
||||
MAX_LOTTERY_BET = 500
|
||||
DEFAULT_LOTTERY_BET = 10
|
||||
MIN_BLACKJACK_BET = 1
|
||||
MAX_BLACKJACK_BET = 500
|
||||
MAX_BLACKJACK_BET = 2_000
|
||||
MIN_BLACKJACK_TABLE_BET = 1
|
||||
MAX_BLACKJACK_TABLE_BET = 10_000
|
||||
MAX_BLACKJACK_TABLE_BET = 2_000
|
||||
MAX_BLACKJACK_TABLE_PLAYERS = 8
|
||||
MIN_TRANSFER_AMOUNT = 1
|
||||
MAX_TRANSFER_AMOUNT = 100_000
|
||||
@@ -51,7 +57,7 @@ WISH_MORA_TABLE = (
|
||||
)
|
||||
WISH_SCAN_TICKET_PERCENT = 10
|
||||
GAME_LABELS = {
|
||||
"prize_draw": "抽奖",
|
||||
"prize_draw": "许愿池",
|
||||
"lottery": "彩票",
|
||||
"blackjack": "21点",
|
||||
}
|
||||
@@ -66,14 +72,14 @@ BLACKJACK_ACTIONS = {
|
||||
"end": "end",
|
||||
}
|
||||
BLACKJACK_START = re.compile(
|
||||
r"^(?:开始|start)(?P<bet>\d{1,3})?$"
|
||||
r"^(?:开始|start)(?P<bet>\d{1,4})?$"
|
||||
)
|
||||
BLACKJACK_COMBINED_ACTION = re.compile(
|
||||
r"^(?:开始|start)(?P<bet>\d{1,3})?"
|
||||
r"^(?:开始|start)(?P<bet>\d{1,4})?"
|
||||
r"(?:要牌|hit|停牌|stand)$"
|
||||
)
|
||||
BLACKJACK_TABLE_BET = re.compile(
|
||||
r"^(?:放|下注|bet)(?P<bet>\d{1,5})$",
|
||||
r"^(?:放|下注|bet)(?P<bet>\d{1,4})$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TRANSFER_MARKED = re.compile(
|
||||
@@ -232,6 +238,19 @@ def recent_primogem_event_lines(
|
||||
return lines
|
||||
|
||||
|
||||
def blackjack_outcome_stat(outcome: object) -> str | None:
|
||||
text = str(outcome or "")
|
||||
if not text or "取消" in text:
|
||||
return None
|
||||
if "平" in text:
|
||||
return "ties"
|
||||
if any(token in text for token in ("输", "认输", "失败", "爆")):
|
||||
return "losses"
|
||||
if "赢" in text or "获胜" in text:
|
||||
return "wins"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
|
||||
account.setdefault("balance", INITIAL_COINS)
|
||||
account.setdefault("game", None)
|
||||
@@ -245,6 +264,12 @@ def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
|
||||
account["scan_boost_tickets"] = (
|
||||
scan_tickets if type(scan_tickets) is int and scan_tickets >= 0 else 0
|
||||
)
|
||||
wish_pool_pity = account.get("wish_pool_pity", 0)
|
||||
account["wish_pool_pity"] = (
|
||||
wish_pool_pity
|
||||
if type(wish_pool_pity) is int and wish_pool_pity >= 0
|
||||
else 0
|
||||
)
|
||||
blackjack_streak = account.get("blackjack_taffy_win_streak", 0)
|
||||
account["blackjack_taffy_win_streak"] = (
|
||||
blackjack_streak
|
||||
@@ -289,6 +314,45 @@ def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
|
||||
account["game_history"] = [
|
||||
entry for entry in history if isinstance(entry, dict)
|
||||
][-GAME_HISTORY_LIMIT:]
|
||||
blackjack_stats = account.get("blackjack_stats")
|
||||
if not isinstance(blackjack_stats, dict):
|
||||
blackjack_stats = {
|
||||
"wins": 0,
|
||||
"losses": 0,
|
||||
"ties": 0,
|
||||
"stake": 0,
|
||||
"payout": 0,
|
||||
}
|
||||
for entry in account["game_history"]:
|
||||
if str(entry.get("kind", "")) != "blackjack":
|
||||
continue
|
||||
stat = blackjack_outcome_stat(entry.get("outcome"))
|
||||
if stat is None:
|
||||
continue
|
||||
try:
|
||||
stake = int(entry.get("stake", 0))
|
||||
payout = int(entry.get("payout", 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
blackjack_stats[stat] += 1
|
||||
blackjack_stats["stake"] += max(0, stake)
|
||||
blackjack_stats["payout"] += max(0, payout)
|
||||
else:
|
||||
blackjack_stats = {
|
||||
key: (
|
||||
int(blackjack_stats.get(key, 0))
|
||||
if type(blackjack_stats.get(key, 0)) is int
|
||||
and int(blackjack_stats.get(key, 0)) >= 0
|
||||
else 0
|
||||
)
|
||||
for key in ("wins", "losses", "ties", "stake", "payout")
|
||||
}
|
||||
blackjack_stats["hands"] = (
|
||||
blackjack_stats["wins"]
|
||||
+ blackjack_stats["losses"]
|
||||
+ blackjack_stats["ties"]
|
||||
)
|
||||
account["blackjack_stats"] = blackjack_stats
|
||||
events = account.get("coin_events")
|
||||
if not isinstance(events, list):
|
||||
events = []
|
||||
@@ -364,6 +428,24 @@ def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
|
||||
return account
|
||||
|
||||
|
||||
def record_blackjack_result(
|
||||
account: dict[str, Any],
|
||||
*,
|
||||
outcome: str,
|
||||
stake: int,
|
||||
payout: int,
|
||||
) -> None:
|
||||
stat = blackjack_outcome_stat(outcome)
|
||||
if stat is None:
|
||||
return
|
||||
normalize_player_account(account)
|
||||
stats = account["blackjack_stats"]
|
||||
stats[stat] += 1
|
||||
stats["hands"] += 1
|
||||
stats["stake"] += max(0, int(stake))
|
||||
stats["payout"] += max(0, int(payout))
|
||||
|
||||
|
||||
def next_game_index(account: dict[str, Any], kind: str) -> int:
|
||||
normalize_player_account(account)
|
||||
counts = account["game_counts"]
|
||||
@@ -525,6 +607,39 @@ class PlayerGames:
|
||||
self.display_label = display_label
|
||||
self.rng = random.SystemRandom()
|
||||
|
||||
@staticmethod
|
||||
def wish_pool_usage() -> str:
|
||||
return (
|
||||
"用法:/许愿池 1000~10000(兼容 /许愿、/抽奖、/draw);"
|
||||
"金额必须按1000递增,每1000摩拉算1愿"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_wish_pool_state(state: dict[str, Any]) -> dict[str, Any]:
|
||||
balance = state.get("balance", 0)
|
||||
state["balance"] = balance if type(balance) is int and balance >= 0 else 0
|
||||
total_wishes = state.get("total_wishes", 0)
|
||||
state["total_wishes"] = (
|
||||
total_wishes
|
||||
if type(total_wishes) is int and total_wishes >= 0
|
||||
else 0
|
||||
)
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def parse_wish_pool_amount(arguments: str) -> int | None:
|
||||
value = arguments.strip()
|
||||
if not value:
|
||||
return 0
|
||||
match = re.fullmatch(
|
||||
r"(?P<amount>\d{1,5})(?:\s*(?:摩拉|金币|coin|coins))?",
|
||||
value,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if match is None:
|
||||
return None
|
||||
return int(match["amount"])
|
||||
|
||||
@staticmethod
|
||||
def rules() -> str:
|
||||
return (
|
||||
@@ -538,17 +653,20 @@ class PlayerGames:
|
||||
"(理论期望3600摩拉)并可能获得扫码概率券;"
|
||||
"/扫码加权 人名 在24小时内给TA整点扫码+5权重;"
|
||||
"/Taffy发话 内容 或 /Taffy发话 定时 HH:MM 内容。\n"
|
||||
"抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n"
|
||||
"许愿池:/许愿池 1000~10000(兼容 /许愿、/抽奖、/draw),"
|
||||
"每1000摩拉1愿,可能获得摩拉回响、扫码概率券、"
|
||||
"Taffy发话额度或原石,8愿无道具后触发保底。\n"
|
||||
"多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];"
|
||||
"/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;"
|
||||
"命中1/2/3个返下注的50%/120%/300%。\n"
|
||||
"21点先用 /21点 开始 [1~500] 进场,金额是本局下注,默认50;空格可省略。\n"
|
||||
"21点先用 /21点 开始 [1~2000] 进场,金额是本局下注,默认50;空格可省略。\n"
|
||||
"看到牌后再单独发送 /21点 要牌|停牌|状态|结束"
|
||||
"(/blackjack hit|stand|status|end);"
|
||||
"/21点 胜率 查看本群所有玩家及Taffy的21点胜率和净收支;"
|
||||
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
|
||||
"多人21点:/21点 多人 启动 创建牌桌;/21点 加入;"
|
||||
"主持人 /21点 多人 开始 后按提示 /21点 放200"
|
||||
"(1~10000);轮到自己 /21点 要牌|停牌。\n"
|
||||
"(1~2000);轮到自己 /21点 要牌|停牌。\n"
|
||||
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
|
||||
"以上均含本金;虚拟摩拉不能充值或兑换。"
|
||||
)
|
||||
@@ -582,10 +700,11 @@ class PlayerGames:
|
||||
if kind == "wallet" and arguments:
|
||||
return "用法:/摩拉(兼容 /金币、/wallet),查看本人的虚拟摩拉;/游戏帮助 查看规则"
|
||||
if kind == "prize_draw":
|
||||
if arguments and not re.fullmatch(r"\d{1,3}", arguments):
|
||||
return "用法:/抽奖 [2~100摩拉](/draw),默认10摩拉"
|
||||
bet = int(arguments or 10)
|
||||
elif kind == "blackjack":
|
||||
return self.wish_pool(row, arguments)
|
||||
if kind == "blackjack":
|
||||
compacted = re.sub(r"\s+", "", arguments.casefold())
|
||||
if compacted in {"胜率", "战绩", "stats", "rate", "rates"}:
|
||||
return self.blackjack_win_rates(row)
|
||||
table_command = self.parse_blackjack_table_command(arguments)
|
||||
if table_command is not None:
|
||||
table_action, table_bet = table_command
|
||||
@@ -595,7 +714,7 @@ class PlayerGames:
|
||||
table_bet,
|
||||
arguments,
|
||||
)
|
||||
compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态"
|
||||
compacted = compacted or "状态"
|
||||
start = BLACKJACK_START.fullmatch(compacted)
|
||||
if start is not None:
|
||||
action = "start"
|
||||
@@ -613,8 +732,9 @@ class PlayerGames:
|
||||
"/21点 要牌 或 /21点 停牌"
|
||||
)
|
||||
return (
|
||||
"用法:先 /21点 开始 [1~500](金额是本局下注,默认50);"
|
||||
"开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略"
|
||||
"用法:先 /21点 开始 [1~2000](金额是本局下注,默认50);"
|
||||
"开局后再单独发送 /21点 要牌|停牌|状态|结束;"
|
||||
"/21点 胜率 查看群内战绩;空格可省略"
|
||||
)
|
||||
group = str(row["group_fingerprint"])
|
||||
if (
|
||||
@@ -640,14 +760,12 @@ class PlayerGames:
|
||||
)
|
||||
else:
|
||||
bet = 0
|
||||
if kind == "prize_draw" and not MIN_DRAW_BET <= bet <= MAX_DRAW_BET:
|
||||
return "抽奖下注必须为 2~100 的整数摩拉"
|
||||
if (
|
||||
kind == "blackjack"
|
||||
and action == "start"
|
||||
and not MIN_BLACKJACK_BET <= bet <= MAX_BLACKJACK_BET
|
||||
):
|
||||
return "21点下注必须为 1~500 的整数摩拉"
|
||||
return "21点下注必须为 1~2000 的整数摩拉"
|
||||
person = self.ledger.resolve_person(
|
||||
str(row["group_fingerprint"]),
|
||||
str(row["sender"]),
|
||||
@@ -684,6 +802,12 @@ class PlayerGames:
|
||||
stake=int(game["bet"]),
|
||||
payout=0,
|
||||
)
|
||||
record_blackjack_result(
|
||||
account,
|
||||
outcome="超时认输",
|
||||
stake=int(game["bet"]),
|
||||
payout=0,
|
||||
)
|
||||
account["game"] = None
|
||||
account["losses"] += 1
|
||||
account["blackjack_taffy_win_streak"] = 0
|
||||
@@ -743,39 +867,210 @@ class PlayerGames:
|
||||
index,
|
||||
)
|
||||
return player_reply(expired + self.show_hand(game), index)
|
||||
index = next_game_index(account, "prize_draw")
|
||||
roll = self.rng.randrange(100)
|
||||
payout = bet * (5 if roll < 5 else 2 if roll < 25 else 0)
|
||||
result = "大奖" if roll < 5 else "小奖" if roll < 25 else "未中奖"
|
||||
account["balance"] += payout
|
||||
account["wins" if payout > bet else "losses"] += 1
|
||||
append_game_record(
|
||||
account,
|
||||
kind="prize_draw",
|
||||
index=index,
|
||||
outcome=result,
|
||||
stake=bet,
|
||||
payout=payout,
|
||||
summary=f"抽奖结果 {result}",
|
||||
)
|
||||
append_game_coin_event(
|
||||
account,
|
||||
kind="prize_draw",
|
||||
outcome=result,
|
||||
stake=bet,
|
||||
payout=payout,
|
||||
)
|
||||
return player_reply(
|
||||
expired
|
||||
+ f"{result};下注 {bet},返还 {payout},"
|
||||
f"净变化 {payout - bet:+d};摩拉 {account['balance']}",
|
||||
index,
|
||||
)
|
||||
raise LocalCommandError("未知本地游戏指令")
|
||||
|
||||
return self.ledger.apply_player_action(
|
||||
str(row["group_fingerprint"]), str(row["sender"]),
|
||||
str(row["event_id"]), kind + ":" + arguments, execute)
|
||||
|
||||
def wish_pool(self, row: dict[str, Any], arguments: str) -> str:
|
||||
amount = self.parse_wish_pool_amount(arguments)
|
||||
if amount is None:
|
||||
return self.wish_pool_usage()
|
||||
if (
|
||||
amount != 0
|
||||
and (
|
||||
amount < MIN_WISH_POOL_AMOUNT
|
||||
or amount > MAX_WISH_POOL_AMOUNT
|
||||
or amount % WISH_POOL_UNIT != 0
|
||||
)
|
||||
):
|
||||
return (
|
||||
"许愿池投入必须为 1000~10000 的整数摩拉,"
|
||||
"且按 1000 递增"
|
||||
)
|
||||
group = str(row["group_fingerprint"])
|
||||
sender = str(row["sender"])
|
||||
event_id = str(row["event_id"])
|
||||
person = self.ledger.resolve_person(group, sender)
|
||||
player_label = self.display_label(person["display_name"])
|
||||
|
||||
def player_reply(text: str, index: int | None = None) -> str:
|
||||
suffix = f" #{index}" if index is not None else ""
|
||||
return f"{player_label}{suffix}:{text}"
|
||||
|
||||
def action(
|
||||
account: dict[str, Any],
|
||||
pool_state: dict[str, Any],
|
||||
) -> str:
|
||||
normalize_player_account(account)
|
||||
self.normalize_wish_pool_state(pool_state)
|
||||
if amount == 0:
|
||||
return (
|
||||
f"{self.wish_pool_usage()};当前池水 {pool_state['balance']};"
|
||||
f"你的保底 {account['wish_pool_pity']}/{WISH_POOL_PITY_LIMIT}"
|
||||
)
|
||||
if account.get("game"):
|
||||
return "你已有一局21点,请先 /21点 停牌 或 /21点 结束"
|
||||
if account["balance"] < amount:
|
||||
return f"摩拉不足:余额 {account['balance']},本次需要 {amount}"
|
||||
|
||||
wishes = amount // WISH_POOL_UNIT
|
||||
index = next_game_index(account, "prize_draw")
|
||||
account["balance"] -= amount
|
||||
pool_gain = 0
|
||||
mora_payout = 0
|
||||
jackpot_payout = 0
|
||||
scan_tickets = 0
|
||||
taffy_credits = 0
|
||||
primogems = 0
|
||||
dry_wishes = 0
|
||||
small_returns = 0
|
||||
medium_returns = 0
|
||||
big_returns = 0
|
||||
pity_hits = 0
|
||||
jackpot_hits = 0
|
||||
|
||||
for _ in range(wishes):
|
||||
pool_state["balance"] += WISH_POOL_CONTRIBUTION
|
||||
pool_state["total_wishes"] += 1
|
||||
pool_gain += WISH_POOL_CONTRIBUTION
|
||||
force_pity = account["wish_pool_pity"] >= WISH_POOL_PITY_LIMIT
|
||||
item_awarded = False
|
||||
if force_pity:
|
||||
pity_hits += 1
|
||||
gift_roll = self.rng.randrange(100)
|
||||
if gift_roll < 70:
|
||||
scan_tickets += 1
|
||||
elif gift_roll < 95:
|
||||
taffy_credits += 1
|
||||
else:
|
||||
primogems += 1
|
||||
item_awarded = True
|
||||
else:
|
||||
roll = self.rng.randrange(1000)
|
||||
if roll < 440:
|
||||
dry_wishes += 1
|
||||
elif roll < 660:
|
||||
small_returns += 1
|
||||
mora_payout += self.rng.randrange(200, 601)
|
||||
elif roll < 780:
|
||||
medium_returns += 1
|
||||
mora_payout += self.rng.randrange(800, 1401)
|
||||
elif roll < 860:
|
||||
scan_tickets += 1
|
||||
item_awarded = True
|
||||
elif roll < 920:
|
||||
taffy_credits += 1
|
||||
item_awarded = True
|
||||
elif roll < 950:
|
||||
primogems += 1
|
||||
item_awarded = True
|
||||
elif roll < 980:
|
||||
big_returns += 1
|
||||
mora_payout += self.rng.randrange(2000, 3501)
|
||||
elif roll < 995:
|
||||
scan_tickets += 1
|
||||
taffy_credits += 1
|
||||
item_awarded = True
|
||||
elif roll < 999:
|
||||
primogems += 2
|
||||
item_awarded = True
|
||||
else:
|
||||
primogems += 2
|
||||
jackpot = min(
|
||||
pool_state["balance"] * WISH_POOL_JACKPOT_PERCENT
|
||||
// 100,
|
||||
WISH_POOL_JACKPOT_CAP,
|
||||
)
|
||||
pool_state["balance"] -= jackpot
|
||||
jackpot_payout += jackpot
|
||||
jackpot_hits += 1
|
||||
item_awarded = True
|
||||
if item_awarded:
|
||||
account["wish_pool_pity"] = 0
|
||||
else:
|
||||
account["wish_pool_pity"] += 1
|
||||
|
||||
total_mora_payout = mora_payout + jackpot_payout
|
||||
account["balance"] += total_mora_payout
|
||||
account["scan_boost_tickets"] += scan_tickets
|
||||
account["taffy_say_credits"] += taffy_credits
|
||||
if primogems:
|
||||
account["primogems"] += primogems
|
||||
append_primogem_event(account, primogems, "许愿池")
|
||||
outcome_parts: list[str] = []
|
||||
if jackpot_hits:
|
||||
outcome_parts.append("池底大愿")
|
||||
if scan_tickets or taffy_credits or primogems or pity_hits:
|
||||
outcome_parts.append("祝福")
|
||||
if big_returns:
|
||||
outcome_parts.append("大回响")
|
||||
elif medium_returns or small_returns:
|
||||
outcome_parts.append("回响")
|
||||
if not outcome_parts:
|
||||
outcome_parts.append("沉底")
|
||||
outcome = "、".join(dict.fromkeys(outcome_parts))
|
||||
append_game_record(
|
||||
account,
|
||||
kind="prize_draw",
|
||||
index=index,
|
||||
outcome=outcome,
|
||||
stake=amount,
|
||||
payout=total_mora_payout,
|
||||
summary=(
|
||||
f"许愿池 {wishes}愿;池水+{pool_gain};"
|
||||
f"摩拉+{total_mora_payout};扫码券+{scan_tickets};"
|
||||
f"发话+{taffy_credits};原石+{primogems}"
|
||||
),
|
||||
)
|
||||
append_game_coin_event(
|
||||
account,
|
||||
kind="prize_draw",
|
||||
outcome=outcome,
|
||||
stake=amount,
|
||||
payout=total_mora_payout,
|
||||
)
|
||||
|
||||
result_parts: list[str] = []
|
||||
if total_mora_payout:
|
||||
mora_text = f"摩拉 +{total_mora_payout}"
|
||||
if jackpot_payout:
|
||||
mora_text += f"(含池底 {jackpot_payout})"
|
||||
result_parts.append(mora_text)
|
||||
if scan_tickets:
|
||||
result_parts.append(f"扫码概率券 +{scan_tickets} 张")
|
||||
if taffy_credits:
|
||||
result_parts.append(f"Taffy发话额度 +{taffy_credits} 次")
|
||||
if primogems:
|
||||
result_parts.append(f"原石 +{primogems} 个")
|
||||
if not result_parts:
|
||||
result_parts.append("池水起了点波纹,啥也没捞到")
|
||||
if pity_hits:
|
||||
result_parts.append(f"触发保底 {pity_hits} 次")
|
||||
return player_reply(
|
||||
(
|
||||
f"投入 {amount} 摩拉,向许愿池许了 {wishes} 愿;"
|
||||
f"结果:{','.join(result_parts)};"
|
||||
f"当前摩拉 {account['balance']},"
|
||||
f"原石 {account['primogems']} 个,"
|
||||
f"扫码概率券 {account['scan_boost_tickets']} 张,"
|
||||
f"Taffy发话额度 {account['taffy_say_credits']} 次;"
|
||||
f"池水 {pool_state['balance']};"
|
||||
f"保底 {account['wish_pool_pity']}/{WISH_POOL_PITY_LIMIT}"
|
||||
),
|
||||
index,
|
||||
)
|
||||
|
||||
return self.ledger.apply_player_local_game_action(
|
||||
group,
|
||||
sender,
|
||||
event_id,
|
||||
"prize_draw:" + str(amount or ""),
|
||||
WISH_POOL_GAME_KEY,
|
||||
action,
|
||||
)
|
||||
|
||||
def primogem_command(
|
||||
self,
|
||||
row: dict[str, Any],
|
||||
@@ -1055,6 +1350,45 @@ class PlayerGames:
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def blackjack_net_text(net: int) -> str:
|
||||
if net > 0:
|
||||
return f"净赚 {net} 摩拉"
|
||||
if net < 0:
|
||||
return f"净亏 {-net} 摩拉"
|
||||
return "净赚净亏 0 摩拉"
|
||||
|
||||
def blackjack_win_rates(self, row: dict[str, Any]) -> str:
|
||||
stats = self.ledger.player_blackjack_stats(
|
||||
str(row["group_fingerprint"])
|
||||
)
|
||||
hands = int(stats["hands"])
|
||||
if hands == 0:
|
||||
return "当前群还没有已结算的21点记录"
|
||||
taffy_wins = int(stats["taffy_wins"])
|
||||
taffy_losses = int(stats["taffy_losses"])
|
||||
ties = int(stats["ties"])
|
||||
taffy_rate = taffy_wins / hands * 100
|
||||
lines = [
|
||||
f"21点胜率:本群已结算 {hands} 手",
|
||||
(
|
||||
f"Taffy:胜 {taffy_wins} / 负 {taffy_losses} / 平 {ties},"
|
||||
f"胜率 {taffy_rate:.2f}%;"
|
||||
f"{self.blackjack_net_text(int(stats['taffy_net']))}"
|
||||
),
|
||||
"玩家:",
|
||||
]
|
||||
for index, player in enumerate(stats["players"], start=1):
|
||||
player_hands = int(player["hands"])
|
||||
win_rate = int(player["wins"]) / player_hands * 100
|
||||
lines.append(
|
||||
f"{index}. {player['display_name']}:"
|
||||
f"胜 {player['wins']} / 负 {player['losses']} / "
|
||||
f"平 {player['ties']},胜率 {win_rate:.2f}%;"
|
||||
f"{self.blackjack_net_text(int(player['net']))}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def parse_blackjack_table_command(
|
||||
arguments: str,
|
||||
@@ -1117,7 +1451,7 @@ class PlayerGames:
|
||||
def blackjack_prompt(player: dict[str, Any]) -> str:
|
||||
return (
|
||||
f"请{player['display_name']} 放自己的筹码\n"
|
||||
"输入/21点 放200(1~10000)"
|
||||
"输入/21点 放200(1~2000)"
|
||||
)
|
||||
|
||||
def group_blackjack(
|
||||
@@ -1135,7 +1469,7 @@ class PlayerGames:
|
||||
return (
|
||||
"多人21点用法:/21点 多人 启动;/21点 加入;"
|
||||
"主持人 /21点 多人 开始;按提示 /21点 放200"
|
||||
"(1~10000);轮到自己 /21点 要牌 或 /21点 停牌"
|
||||
"(1~2000);轮到自己 /21点 要牌 或 /21点 停牌"
|
||||
)
|
||||
if action == "start":
|
||||
return self.ledger.start_group_blackjack(
|
||||
@@ -1181,7 +1515,7 @@ class PlayerGames:
|
||||
)
|
||||
if action == "bet":
|
||||
if not MIN_BLACKJACK_TABLE_BET <= amount <= MAX_BLACKJACK_TABLE_BET:
|
||||
return "21点多人牌桌筹码必须为 1~10000 的整数摩拉"
|
||||
return "21点多人牌桌筹码必须为 1~2000 的整数摩拉"
|
||||
deck = list(range(52))
|
||||
self.rng.shuffle(deck)
|
||||
return self.ledger.place_group_blackjack_bet(
|
||||
@@ -1868,6 +2202,12 @@ class PlayerGames:
|
||||
stake=bet,
|
||||
payout=0,
|
||||
)
|
||||
record_blackjack_result(
|
||||
account,
|
||||
outcome="主动认输",
|
||||
stake=bet,
|
||||
payout=0,
|
||||
)
|
||||
account["game"] = None
|
||||
account["losses"] += 1
|
||||
account["blackjack_taffy_win_streak"] = 0
|
||||
@@ -1922,6 +2262,12 @@ class PlayerGames:
|
||||
stake=int(bet),
|
||||
payout=int(payout),
|
||||
)
|
||||
record_blackjack_result(
|
||||
account,
|
||||
outcome=outcome,
|
||||
stake=int(bet),
|
||||
payout=int(payout),
|
||||
)
|
||||
achievements = blackjack_achievement_lines(
|
||||
account,
|
||||
outcome=outcome,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-sdk"
|
||||
version = "2.0.2b1+1021"
|
||||
version = "2.0.4b1+1023"
|
||||
description = "Typed, capability-limited extension API for JWeBot plugins"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = []
|
||||
|
||||
@@ -40,4 +40,4 @@ __all__ = [
|
||||
"SingleGroupContext",
|
||||
]
|
||||
|
||||
__version__ = "2.0.2b1+1021"
|
||||
__version__ = "2.0.4b1+1023"
|
||||
|
||||
Reference in New Issue
Block a user