Release beta 2.0.4.1023

This commit is contained in:
JWeBot Contributors
2026-10-23 17:18:29 +08:00
parent 22f5240fa7
commit 6375ab5c79
32 changed files with 2286 additions and 233 deletions
+1 -1
View File
@@ -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"
+7 -4
View File
@@ -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"
@@ -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",
@@ -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,
+1 -1
View File
@@ -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"