feat: add multiplayer lottery and game records
check / local-gate (push) Failing after 1m35s

This commit is contained in:
JWeBot Contributors
2026-10-15 13:57:32 +08:00
parent 0731e5cebb
commit f07ae653c7
29 changed files with 1463 additions and 128 deletions
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## alpha 0.6.3.1015
- Replace instant personal lottery tickets with one persistent multiplayer
lottery round per group: start, register/update, status, draw, and cancel.
- Treat each ticket as three distinct digits from 0-9. Sort digits before
storage so `347` and `743` represent the same selection.
- Accept spaced and compact OCR forms. Ticket stakes are 1-500 coins with a
default of 10 when omitted.
- Return 0%, 50%, 120%, or 300% of the stake for matching zero, one, two, or
three drawn digits. All settlement values remain integer coins.
- Commit entry updates, stake differences, all-player payouts, cancellation
refunds, and event replay responses in serialized SQLite transactions.
- Raise the blackjack stake range to 1-500 coins.
- Add per-game personal sequence numbers and `/游戏记录` (`/gamehistory`) for
prize draws, lottery rounds, and blackjack hands.
- Prefix game output with the configured player label and game number, such as
`Jacky #1:...`; aliases share the same counter and account.
## alpha 0.6.2.1015
- Bound direct DeepSeek SSE reads to 30 seconds of total wall time in addition
+11 -4
View File
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。
当前版本:`alpha 0.6.2.1015`。
当前版本:`alpha 0.6.3.1015`。
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
@@ -17,6 +17,9 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
占满生成 worker;21 点开局金额明确作为本局下注,并兼容 OCR 丢失空格。
21 点牌面和结算以当前玩家展示名开头。快速 ACK、重试和严格顺序投递
规则保持不变。
0.6.3 将彩票改为群内多人轮次,加入报名、状态、主持人开奖和取消;
抽奖、彩票、21 点统一使用 `Jacky #N:...` 局号并支持 `/游戏记录`。
完整玩法见[多人彩票与游戏记录](docs/group-lottery-0.6.3.md)。
### 运行流程
@@ -83,8 +86,9 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/上下文 [100|200|300](/context)
/人物列表 [页码](/people);/人物 [名称或别名](/person)
/金币(/wallet);/抽奖 [金额](/draw)
/买彩票 [000~999] [金额](/lottery)
/21点 开始 [金额];开局后 /21点 要牌|停牌|状态|结束
/彩票 开始|报名|当前状态|开奖|取消(/lottery)
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
/游戏记录 [页码](/gamehistory)
/游戏帮助(/games)
/丢骰子 [NdM]
/猜数字 开始 [上限]
@@ -254,7 +258,7 @@ visually confirmed GUI bridge.
Current version:
```text
alpha 0.6.2.1015
alpha 0.6.3.1015
```
## Runtime Flow
@@ -364,6 +368,9 @@ unfinished upstream response cannot occupy both generation workers for about
forms are accepted, while hit/stand remain explicit follow-up commands.
Blackjack hand and settlement replies start with the current player's configured
display label. ACKs, retries, and ordered final delivery remain unchanged.
Version 0.6.3 replaces instant lottery tickets with persistent multiplayer
group rounds, adds indexed personal game history, and raises blackjack stakes
to 1-500 coins. See [multiplayer lottery and game records](docs/group-lottery-0.6.3.md).
Steam prefers private per-person SteamID mappings from the active profile and
uses the corresponding group display IDs and aliases. Without a private roster,
+1 -1
View File
@@ -1 +1 @@
alpha 0.6.2.1015
alpha 0.6.3.1015
+68
View File
@@ -0,0 +1,68 @@
# alpha 0.6.3.1015 多人彩票与游戏记录
## 一把彩票怎么玩
1. 任意群友发送 `/彩票 开始`。发起人是本轮主持人。
2. 每个人发送 `/彩票 报名 3 4 7 100`,表示选择 3、4、7,下注 100
金币。也可简写为 `/彩票347100`;省略金额时默认下注 10。
3. 发送 `/彩票 当前状态` 查看本轮主持人、报名人数、每个人的号码和下注。
4. 主持人发送 `/彩票 开奖`。机器人从 0~9 中抽取三个互不重复的数字,
统一排序后结算。
5. 主持人也可在开奖前发送 `/彩票 取消`,所有报名者全额退款。
命令关键词和数字之间的空格均可省略。`/买彩票` 和 `/lottery` 保留为
对应别名。
## 选号与报名
- 每张票必须是三个互不重复的 0~9 数字。
- 数字顺序不影响结果:`347`、`743` 和 `3 4 7` 都保存为 `3 4 7`。
- 每人每把只有一张票。再次报名会更新号码和下注,不增加个人局号。
- 更新到更高下注时只补扣差额;降低下注时退回差额。
- 下注范围是 1~500,省略时为 10。
- 没有进行中的彩票时不能报名;没有报名者时不能开奖。
- 只有创建本轮的主持人可以开奖或取消,状态查询对群内所有人开放。
支持的报名示例:
```text
/彩票 347
/彩票 743 100
/彩票 报名 3 4 7 100
/彩票报名347金额100
/彩票347100
/lottery join 347 100
```
## 返还规则
报名时先扣除下注。开奖号和玩家号码按集合比较,返还包含本金:
| 命中数 | 返还 |
|---|---:|
| 0 个 | 0% |
| 1 个 | 50% |
| 2 个 | 120% |
| 3 个 | 300% |
金币只使用整数;百分比计算的小数部分向下取整。例如下注 100,命中两个
返还 120,净变化为 `+20`。
## 个人局号与记录
抽奖、彩票和 21 点分别维护本人的游戏局号。第一次参与某种游戏显示
`Jacky #1:...`,下一把显示 `Jacky #2:...`。配置别名解析到同一人物时,
共用同一账户、局号和历史。
发送 `/游戏记录 [页码]`、`/个人记录 [页码]` 或
`/gamehistory [page]` 查看最近记录,每页五条。记录包含游戏、个人局号、
结果、下注、返还和净变化,最多保留最近 50 条。
21 点下注范围同步调整为 1~500。`/21点 开始 100` 只表示本局下注 100;
发牌后再单独发送 `/21点 要牌` 或 `/21点 停牌`。
## 持久化与并发
彩票轮次、报名和结算保存在 Edge SQLite。报名/改注、所有玩家的开奖返还、
取消退款和事件重放响应都在 `BEGIN IMMEDIATE` 事务中提交。重复事件不会
重复扣款、发奖或增加局号;进程重启后未开奖轮次和个人记录仍然保留。
+12 -9
View File
@@ -9,9 +9,10 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,
| context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` |
| single-player-games | `/金币` | `/wallet` |
| single-player-games | `/抽奖 [金额]` | `/draw [金额]` |
| single-player-games | `/买彩票 [000~999] [金额]` | `/lottery [000~999] [金额]` |
| single-player-games | `/彩票 开始/报名/当前状态/开奖/取消` | `/lottery start/join/status/draw/cancel` |
| single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) |
| single-player-games | `/21点 要牌/停牌/状态/结束` | `/blackjack hit/stand/status/end` |
| single-player-games | `/游戏记录 [页码]` | `/gamehistory [page]` |
| single-player-games | `/游戏帮助` | `/games` |
| person-directory | `/人物列表 [页码]`、`/别名列表 [页码]` | `/people [页码]` |
| person-directory | `/人物 [名称或别名]`、`/个人属性 [名称或别名]` | `/person [名称或别名]` |
@@ -51,27 +52,29 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使
应优先追加别名而不是重命名。用户不能通过游戏命令指定他人的账户,
不能充值、转账、赠币或兑换真实资产。
## 单人游戏管理
## 虚拟金币与游戏管理
每个群内人物首次使用账户时一次性获得 1000 虚拟金币。再次查询、
重启服务或切换已配置别名都不会重复领取。下注为 2~100 的整数,
余额不足或参数非法不扣款。
重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和
21 点下注为 1~500;余额不足或参数非法不扣款。
抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。
彩票默认随机三位号码、下注 10;可指定 000~999,命中返还五百倍。
所有返还都包含本金,回复同时报告下注、返还、净变化和余额。
alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关,
下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见
[多人彩票与游戏记录](group-lottery-0.6.3.md)。
21 点默认下注 50。`/21点 开始 100` 表示拿 100 金币作为本局下注进场,
21 点默认下注 50、范围 1~500。`/21点 开始 100` 表示拿 100 金币作为本局下注进场,
等价的 OCR 紧凑形式是 `/21点开始100`。开局会扣除下注并发两张牌,
然后根据牌面单独发送 `/21点 要牌` 或 `/21点 停牌`;开局与操作写在
同一条消息时不会扣款,而会返回分步提示。牌局使用洗牌后的单副 52 张牌,
牌堆、双方手牌、下注和最后操作时间持久化。A 可作为 1 或 11;
庄家软 17 停牌。开始后只公开一张庄家牌;要牌、停牌、认输和查询都
仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头,
例如 `jacky:你的牌 ...`;别名解析到同一人物时沿用相同展示名和账户。
例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、
账户、局号和游戏记录。
自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍,
平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔
抽奖、彩票或开局。
抽奖或 21 点开局,多人彩票报名仍可独立进行。
十分钟无有效游戏操作的牌局在下一次账户操作时按认输结束;查询状态
不延长有效期。主动结束同样按认输处理,不退下注。其他人可以各自
@@ -2,12 +2,13 @@
Author: `jwebot`
本人虚拟金币账户、抽奖、彩票和单人21点
虚拟金币账户、抽奖、群内多人彩票和单人21点
Help:
- `/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery`
- `/21点 开始 [金额](金额=本局下注,默认50,空格可省略)`
- `/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory`
- `/彩票 开始|报名|当前状态|开奖|取消;/lottery`
- `/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)`
- `/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end`
- `/游戏帮助;/games`
@@ -17,11 +18,15 @@ Registered commands:
- `/wallet`
- `/抽奖`
- `/draw`
- `/彩票`
- `/买彩票`
- `/lottery`
- `/21点`
- `/二十一点`
- `/blackjack`
- `/游戏记录`
- `/个人记录`
- `/gamehistory`
- `/游戏帮助`
- `/games`
@@ -1,14 +1,14 @@
id = "single-player-games"
author = "jwebot"
description = "本人虚拟金币账户、抽奖、彩票和单人21点"
help = ["/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery", "/21点 开始 [金额](金额=本局下注,默认50,空格可省略)", "/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", "/游戏帮助;/games"]
description = "虚拟金币账户、抽奖、群内多人彩票和单人21点"
help = ["/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory", "/彩票 开始|报名|当前状态|开奖|取消;/lottery", "/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)", "/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", "/游戏帮助;/games"]
version = "1.0.0"
api_version = "1"
entrypoint = "single_player_games:plugin"
type = "command"
[capabilities]
commands = ["/金币", "/wallet", "/抽奖", "/draw", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏帮助", "/games"]
commands = ["/金币", "/wallet", "/抽奖", "/draw", "/彩票", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏记录", "/个人记录", "/gamehistory", "/游戏帮助", "/games"]
storage = false
network_profiles = []
service_profiles = ["single-player-games"]
@@ -5,9 +5,9 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games'
AUTHOR = 'jwebot'
DESCRIPTION = '本人虚拟金币账户、抽奖、彩票和单人21点'
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始 [金额](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点'
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
def _handler(context: SingleGroupContext, command: str):
@@ -19,10 +19,10 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '本人虚拟金币账户、抽奖、彩票和单人21点')
self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始 [金额](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games'))
self.assertEqual(DESCRIPTION, '虚拟金币账户、抽奖、群内多人彩票和单人21点')
self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games'))
self.assertIn('/金币', registered)
self.assertEqual(len(registered), 11)
self.assertEqual(len(registered), 15)
if __name__ == "__main__":
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-config"
version = "0.6.2a0+1015"
version = "0.6.3a0+1015"
description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9"
dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile",
]
__version__ = "0.6.2a0+1015"
__version__ = "0.6.3a0+1015"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-extension-host"
version = "0.6.2a0+1015"
version = "0.6.3a0+1015"
description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==0.6.2a0+1015",
"jwebot-kernel==0.6.2a0+1015",
"jwebot-sdk==0.6.3a0+1015",
"jwebot-kernel==0.6.3a0+1015",
]
[project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request",
]
__version__ = "0.6.2a0+1015"
__version__ = "0.6.3a0+1015"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-kernel"
version = "0.6.2a0+1015"
version = "0.6.3a0+1015"
description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-config==0.6.2a0+1015",
"jwebot-sdk==0.6.2a0+1015",
"jwebot-official-extensions==0.6.2a0+1015",
"jwebot-config==0.6.3a0+1015",
"jwebot-sdk==0.6.3a0+1015",
"jwebot-official-extensions==0.6.3a0+1015",
"tomli>=2; python_version < '3.11'",
]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature",
]
__version__ = "0.6.2a0+1015"
__version__ = "0.6.3a0+1015"
@@ -68,6 +68,11 @@ from jwebot_official_extensions.steam import (
SteamClient,
SteamUnavailable,
)
from jwebot_official_extensions.player_games import (
append_game_record,
next_game_index,
normalize_player_account,
)
from jwebot_sdk import Event, PlanKind
@@ -1026,6 +1031,45 @@ class Ledger:
created_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, event_id)
);
CREATE TABLE IF NOT EXISTS group_lottery_rounds (
group_fingerprint TEXT NOT NULL,
round_number INTEGER NOT NULL,
state TEXT NOT NULL,
host_person_key TEXT NOT NULL,
host_display_name TEXT NOT NULL,
drawn_numbers_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, round_number)
);
CREATE TABLE IF NOT EXISTS group_lottery_entries (
group_fingerprint TEXT NOT NULL,
round_number INTEGER NOT NULL,
person_key TEXT NOT NULL,
display_name TEXT NOT NULL,
player_game_index INTEGER NOT NULL,
numbers_json TEXT NOT NULL,
stake INTEGER NOT NULL,
matches INTEGER NOT NULL DEFAULT 0,
payout INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (
group_fingerprint, round_number, person_key
),
FOREIGN KEY (group_fingerprint, round_number)
REFERENCES group_lottery_rounds (
group_fingerprint, round_number
)
ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS group_lottery_one_open
ON group_lottery_rounds(group_fingerprint)
WHERE state = 'open';
CREATE INDEX IF NOT EXISTS group_lottery_entries_round
ON group_lottery_entries(
group_fingerprint, round_number, created_at, person_key
);
CREATE INDEX IF NOT EXISTS events_group_created
ON events(group_fingerprint, created_at);
CREATE INDEX IF NOT EXISTS outbox_due
@@ -2983,49 +3027,606 @@ class Ledger:
})
return list(people.values())
def _player_identity(self, group: str, sender: str) -> dict[str, str]:
if not group or not sender or sender == "unknown":
raise LocalCommandError("无法确认本人身份,不能操作金币账户")
person = self.resolve_person(group, sender)
person["display_name"] = person_display_label(person["display_name"])
return person
def _player_action_replay(
self,
group: str,
event_id: str,
person_key: str,
command: str,
) -> str | None:
previous = self.connection.execute(
"""
SELECT person_key, command, response_text FROM player_actions
WHERE group_fingerprint = ? AND event_id = ?
""",
(group, event_id),
).fetchone()
if previous is None:
return None
if previous["person_key"] != person_key or previous["command"] != command:
raise LocalCommandError("同一事件不能变更账户或游戏指令")
return str(previous["response_text"])
def _load_player_account(
self,
group: str,
person_key: str,
) -> dict[str, Any]:
stored = self.connection.execute(
"""
SELECT state_json FROM player_accounts
WHERE group_fingerprint = ? AND person_key = ?
""",
(group, person_key),
).fetchone()
if stored is None:
return normalize_player_account({})
try:
account = json.loads(str(stored["state_json"]))
except json.JSONDecodeError as error:
raise LocalCommandError("金币账户状态无效") from error
if not isinstance(account, dict):
raise LocalCommandError("金币账户状态无效")
return normalize_player_account(account)
def _save_player_account(
self,
group: str,
person_key: str,
account: dict[str, Any],
timestamp: str,
) -> None:
normalize_player_account(account)
if (
type(account["balance"]) is not int
or not 0 <= account["balance"] <= 1_000_000_000
):
raise LocalCommandError("金币结算超出允许范围")
self.connection.execute(
"""
INSERT INTO player_accounts VALUES (?, ?, ?, ?)
ON CONFLICT(group_fingerprint, person_key) DO UPDATE
SET state_json = excluded.state_json,
updated_at = excluded.updated_at
""",
(
group,
person_key,
json.dumps(account, ensure_ascii=False),
timestamp,
),
)
def _store_player_action(
self,
group: str,
event_id: str,
person_key: str,
command: str,
response: str,
timestamp: str,
) -> None:
self.connection.execute(
"INSERT INTO player_actions VALUES (?, ?, ?, ?, ?, ?)",
(group, event_id, person_key, command, response, timestamp),
)
def _open_group_lottery(self, group: str) -> sqlite3.Row | None:
return self.connection.execute(
"""
SELECT * FROM group_lottery_rounds
WHERE group_fingerprint = ? AND state = 'open'
LIMIT 1
""",
(group,),
).fetchone()
def _group_lottery_entries(
self,
group: str,
round_number: int,
) -> list[dict[str, Any]]:
rows = self.connection.execute(
"""
SELECT * FROM group_lottery_entries
WHERE group_fingerprint = ? AND round_number = ?
ORDER BY created_at, person_key
""",
(group, round_number),
).fetchall()
entries: list[dict[str, Any]] = []
for row in rows:
entry = dict(row)
try:
numbers = json.loads(str(entry["numbers_json"]))
except json.JSONDecodeError:
numbers = []
entry["numbers"] = [
int(value)
for value in numbers
if type(value) is int and 0 <= value <= 9
]
entries.append(entry)
return entries
def start_group_lottery(
self,
group: str,
sender: str,
event_id: str,
command: str,
render: Callable[[dict[str, Any]], str],
) -> str:
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
row = self._open_group_lottery(group)
status = "already_open"
if row is None:
round_number = int(
self.connection.execute(
"""
SELECT COALESCE(MAX(round_number), 0) + 1
FROM group_lottery_rounds
WHERE group_fingerprint = ?
""",
(group,),
).fetchone()[0]
)
timestamp = utc_now()
self.connection.execute(
"""
INSERT INTO group_lottery_rounds (
group_fingerprint, round_number, state,
host_person_key, host_display_name,
drawn_numbers_json, created_at, updated_at
) VALUES (?, ?, 'open', ?, ?, '[]', ?, ?)
""",
(
group,
round_number,
person_key,
person["display_name"],
timestamp,
timestamp,
),
)
row = self._open_group_lottery(group)
status = "started"
if row is None:
raise LocalCommandError("彩票创建失败")
data = dict(row)
data["status"] = status
response = render(data)
timestamp = utc_now()
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 group_lottery_status(
self,
group: str,
sender: str,
event_id: str,
command: str,
render: Callable[[dict[str, Any]], str],
) -> str:
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
row = self._open_group_lottery(group)
if row is None:
data: dict[str, Any] = {
"status": "no_round",
"entries": [],
}
else:
data = dict(row)
data["status"] = "open"
data["entries"] = self._group_lottery_entries(
group, int(row["round_number"])
)
response = render(data)
timestamp = utc_now()
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 register_group_lottery(
self,
group: str,
sender: str,
event_id: str,
command: str,
numbers: tuple[int, int, int],
stake: int,
render: Callable[[dict[str, Any]], str],
) -> str:
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
row = self._open_group_lottery(group)
if row is None:
data: dict[str, Any] = {
"status": "no_round",
"display_name": person["display_name"],
}
else:
round_number = int(row["round_number"])
existing = self.connection.execute(
"""
SELECT * FROM group_lottery_entries
WHERE group_fingerprint = ? AND round_number = ?
AND person_key = ?
""",
(group, round_number, person_key),
).fetchone()
account = self._load_player_account(group, person_key)
old_stake = int(existing["stake"]) if existing else 0
delta = stake - old_stake
if delta > int(account["balance"]):
data = {
"status": "insufficient",
"display_name": person["display_name"],
"balance": int(account["balance"]),
}
else:
account["balance"] -= delta
index = (
int(existing["player_game_index"])
if existing
else next_game_index(account, "lottery")
)
timestamp = utc_now()
self._save_player_account(
group, person_key, account, timestamp
)
self.connection.execute(
"""
INSERT INTO group_lottery_entries (
group_fingerprint, round_number, person_key,
display_name, player_game_index, numbers_json,
stake, matches, payout, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
ON CONFLICT (
group_fingerprint, round_number, person_key
) DO UPDATE SET
display_name = excluded.display_name,
numbers_json = excluded.numbers_json,
stake = excluded.stake,
matches = 0,
payout = 0,
updated_at = excluded.updated_at
""",
(
group,
round_number,
person_key,
person["display_name"],
index,
canonical_json(list(numbers)),
stake,
timestamp,
timestamp,
),
)
data = {
"status": "updated" if existing else "registered",
"display_name": person["display_name"],
"player_game_index": index,
"round_number": round_number,
"numbers": list(numbers),
"stake": stake,
"balance": int(account["balance"]),
}
response = render(data)
timestamp = utc_now()
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 draw_group_lottery(
self,
group: str,
sender: str,
event_id: str,
command: str,
drawn_numbers: tuple[int, int, int],
render: Callable[[dict[str, Any]], str],
) -> str:
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
if (
len(drawn_numbers) != 3
or len(set(drawn_numbers)) != 3
or any(type(value) is not int or not 0 <= value <= 9 for value in drawn_numbers)
):
raise LocalCommandError("彩票开奖号无效")
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
row = self._open_group_lottery(group)
if row is None:
data: dict[str, Any] = {"status": "no_round"}
elif str(row["host_person_key"]) != person_key:
data = {
"status": "not_host",
"host_display_name": str(row["host_display_name"]),
}
else:
round_number = int(row["round_number"])
entries = self._group_lottery_entries(
group, round_number
)
if not entries:
data = {
"status": "empty",
"round_number": round_number,
}
else:
timestamp = utc_now()
settled: list[dict[str, Any]] = []
drawn = set(drawn_numbers)
rates = {0: 0, 1: 50, 2: 120, 3: 300}
for entry in entries:
key = str(entry["person_key"])
account = self._load_player_account(group, key)
matches = len(drawn.intersection(entry["numbers"]))
stake = int(entry["stake"])
payout = stake * rates[matches] // 100
account["balance"] += payout
account[
"wins" if payout > stake else "losses"
] += 1
append_game_record(
account,
kind="lottery",
index=int(entry["player_game_index"]),
outcome=f"命中{matches}个",
stake=stake,
payout=payout,
summary=(
"选号 "
+ " ".join(map(str, entry["numbers"]))
+ ";开奖号 "
+ " ".join(map(str, drawn_numbers))
),
)
self._save_player_account(
group, key, account, timestamp
)
self.connection.execute(
"""
UPDATE group_lottery_entries
SET matches = ?, payout = ?, updated_at = ?
WHERE group_fingerprint = ?
AND round_number = ? AND person_key = ?
""",
(
matches,
payout,
timestamp,
group,
round_number,
key,
),
)
result = dict(entry)
result.update(
{
"matches": matches,
"payout": payout,
"balance": int(account["balance"]),
}
)
settled.append(result)
self.connection.execute(
"""
UPDATE group_lottery_rounds
SET state = 'drawn', drawn_numbers_json = ?,
updated_at = ?
WHERE group_fingerprint = ? AND round_number = ?
""",
(
canonical_json(list(drawn_numbers)),
timestamp,
group,
round_number,
),
)
data = {
"status": "drawn",
"round_number": round_number,
"drawn_numbers": list(drawn_numbers),
"entries": settled,
}
response = render(data)
timestamp = utc_now()
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 cancel_group_lottery(
self,
group: str,
sender: str,
event_id: str,
command: str,
render: Callable[[dict[str, Any]], str],
) -> str:
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
row = self._open_group_lottery(group)
if row is None:
data: dict[str, Any] = {"status": "no_round"}
elif str(row["host_person_key"]) != person_key:
data = {
"status": "not_host",
"host_display_name": str(row["host_display_name"]),
}
else:
round_number = int(row["round_number"])
entries = self._group_lottery_entries(
group, round_number
)
timestamp = utc_now()
for entry in entries:
key = str(entry["person_key"])
stake = int(entry["stake"])
account = self._load_player_account(group, key)
account["balance"] += stake
append_game_record(
account,
kind="lottery",
index=int(entry["player_game_index"]),
outcome="本轮取消",
stake=stake,
payout=stake,
summary="主持人取消,下注已全额退回",
)
self._save_player_account(
group, key, account, timestamp
)
self.connection.execute(
"""
UPDATE group_lottery_rounds
SET state = 'cancelled', updated_at = ?
WHERE group_fingerprint = ? AND round_number = ?
""",
(timestamp, group, round_number),
)
data = {
"status": "cancelled",
"round_number": round_number,
"entries": entries,
}
response = render(data)
timestamp = utc_now()
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 player_game_history(
self,
group: str,
sender: str,
) -> dict[str, Any]:
person = self._player_identity(group, sender)
with self.lock:
account = self._load_player_account(
group, str(person["person_key"])
)
history = sorted(
account["game_history"],
key=lambda entry: float(entry.get("completed_at", 0)),
reverse=True,
)
return {
"display_name": person["display_name"],
"history": history,
"game_counts": dict(account["game_counts"]),
}
def apply_player_action(
self, group: str, sender: str, event_id: str, command: str,
action: Callable[[dict[str, Any]], str],
) -> str:
"""Commit identity-scoped funds, game state and replay response together."""
if not group or not event_id or not sender or sender == "unknown":
raise LocalCommandError("无法确认本人身份,不能操作金币账户")
person = self.resolve_person(group, sender)
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
previous = self.connection.execute(
"""SELECT person_key, command, response_text FROM player_actions
WHERE group_fingerprint = ? AND event_id = ?""",
(group, event_id),
).fetchone()
if previous is not None:
if previous["person_key"] != person_key or previous["command"] != command:
raise LocalCommandError("同一事件不能变更账户或游戏指令")
self.connection.execute("COMMIT")
return str(previous["response_text"])
stored = self.connection.execute(
"""SELECT state_json FROM player_accounts
WHERE group_fingerprint = ? AND person_key = ?""",
(group, person_key),
).fetchone()
account = json.loads(stored["state_json"]) if stored else {
"balance": 1000, "game": None, "wins": 0, "losses": 0, "ties": 0,
}
response = action(account)
if type(account["balance"]) is not int or not 0 <= account["balance"] <= 1_000_000_000:
raise LocalCommandError("金币结算超出允许范围")
timestamp = utc_now()
self.connection.execute(
"""INSERT INTO player_accounts VALUES (?, ?, ?, ?)
ON CONFLICT(group_fingerprint, person_key) DO UPDATE
SET state_json = excluded.state_json, updated_at = excluded.updated_at""",
(group, person_key, json.dumps(account, ensure_ascii=False), timestamp),
replay = self._player_action_replay(
group, event_id, person_key, command
)
self.connection.execute(
"INSERT INTO player_actions VALUES (?, ?, ?, ?, ?, ?)",
(group, event_id, person_key, command, response, timestamp),
if replay is not None:
self.connection.execute("COMMIT")
return replay
account = self._load_player_account(group, person_key)
response = action(account)
timestamp = utc_now()
self._save_player_account(
group, person_key, account, timestamp
)
self._store_player_action(
group,
event_id,
person_key,
command,
response,
timestamp,
)
self.connection.execute("COMMIT")
return response
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-official-extensions"
version = "0.6.2a0+1015"
version = "0.6.3a0+1015"
description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==0.6.2a0+1015",
"jwebot-sdk==0.6.3a0+1015",
]
[tool.setuptools.packages.find]
@@ -62,4 +62,4 @@ __all__ = [
"render_feature_list",
]
__version__ = "0.6.2a0+1015"
__version__ = "0.6.3a0+1015"
@@ -5,9 +5,9 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games'
AUTHOR = 'jwebot'
DESCRIPTION = '本人虚拟金币账户、抽奖、彩票和单人21点'
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始 [金额](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点'
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
def _handler(context: SingleGroupContext, command: str):
@@ -44,11 +44,15 @@ LOCAL_COMMAND_ALIASES = {
"wallet": "wallet",
"抽奖": "prize_draw",
"draw": "prize_draw",
"彩票": "lottery",
"买彩票": "lottery",
"lottery": "lottery",
"21点": "blackjack",
"二十一点": "blackjack",
"blackjack": "blackjack",
"游戏记录": "game_history",
"个人记录": "game_history",
"gamehistory": "game_history",
"游戏帮助": "game_rules",
"games": "game_rules",
"人物列表": "person_directory",
@@ -120,14 +124,22 @@ LOCAL_EXTENSIONS = (
LocalExtensionDescriptor(
extension_id="single-player-games",
author=JWEBOT_AUTHOR,
description="本人虚拟金币账户、抽奖、彩票和单人21点",
description="虚拟金币账户、抽奖、群内多人彩票和单人21点",
help_entries=(
"/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery",
"/21点 开始 [金额](金额=本局下注,默认50,空格可省略)",
"/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory",
"/彩票 开始|报名|当前状态|开奖|取消;/lottery",
"/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)",
"/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end",
"/游戏帮助;/games",
),
command_kinds=("wallet", "prize_draw", "lottery", "blackjack", "game_rules"),
command_kinds=(
"wallet",
"prize_draw",
"lottery",
"blackjack",
"game_history",
"game_rules",
),
),
LocalExtensionDescriptor(
extension_id="person-directory",
@@ -119,7 +119,14 @@ class LocalCommandEngine:
return self.reply_cleaner(self.context_summary(row, arguments))
if kind in {"person_directory", "person_info"}:
return self.reply_cleaner(self.person_index(row, kind, arguments))
if kind in {"wallet", "prize_draw", "lottery", "blackjack", "game_rules"}:
if kind in {
"wallet",
"prize_draw",
"lottery",
"blackjack",
"game_history",
"game_rules",
}:
return self.reply_cleaner(self.players.handle(row, kind, arguments))
if kind == "dice":
return self.reply_cleaner(self.dice(row, arguments))
@@ -9,8 +9,21 @@ from typing import Any
INITIAL_COINS = 1000
MAX_BET = 100
MIN_DRAW_BET = 2
MAX_DRAW_BET = 100
MIN_LOTTERY_BET = 1
MAX_LOTTERY_BET = 500
DEFAULT_LOTTERY_BET = 10
MIN_BLACKJACK_BET = 1
MAX_BLACKJACK_BET = 500
GAME_TIMEOUT_SECONDS = 600
GAME_HISTORY_LIMIT = 50
GAME_HISTORY_PAGE_SIZE = 5
GAME_LABELS = {
"prize_draw": "抽奖",
"lottery": "彩票",
"blackjack": "21点",
}
BLACKJACK_ACTIONS = {
"要牌": "hit",
"hit": "hit",
@@ -30,6 +43,68 @@ BLACKJACK_COMBINED_ACTION = re.compile(
)
def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
account.setdefault("balance", INITIAL_COINS)
account.setdefault("game", None)
account.setdefault("wins", 0)
account.setdefault("losses", 0)
account.setdefault("ties", 0)
counts = account.get("game_counts")
if not isinstance(counts, dict):
counts = {}
account["game_counts"] = counts
for kind in GAME_LABELS:
value = counts.get(kind, 0)
counts[kind] = value if type(value) is int and value >= 0 else 0
history = account.get("game_history")
if not isinstance(history, list):
history = []
account["game_history"] = [
entry for entry in history if isinstance(entry, dict)
][-GAME_HISTORY_LIMIT:]
return account
def next_game_index(account: dict[str, Any], kind: str) -> int:
normalize_player_account(account)
counts = account["game_counts"]
counts[kind] = int(counts.get(kind, 0)) + 1
return int(counts[kind])
def append_game_record(
account: dict[str, Any],
*,
kind: str,
index: int,
outcome: str,
stake: int,
payout: int,
summary: str,
) -> None:
normalize_player_account(account)
history = [
entry
for entry in account["game_history"]
if not (
entry.get("kind") == kind
and int(entry.get("index", 0)) == index
)
]
history.append(
{
"kind": kind,
"index": index,
"outcome": outcome[:80],
"stake": stake,
"payout": payout,
"summary": summary[:240],
"completed_at": time.time(),
}
)
account["game_history"] = history[-GAME_HISTORY_LIMIT:]
def card_label(card: int) -> str:
return ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")[card % 13]
@@ -52,13 +127,15 @@ class PlayerGames:
@staticmethod
def rules() -> str:
return (
"单人游戏:每人首次使用获 1000 虚拟金币;每次下注 2~100。\n"
"/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [000~999] [金额] /lottery;"
"21点先用 /21点 开始 [金额] 进场,金额是本局下注,默认50;空格可省略。\n"
"每人首次使用获 1000 虚拟金币;/金币 查看余额;/游戏记录 查看本人记录。\n"
"抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n"
"多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];"
"/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;"
"命中1/2/3个返下注的50%/120%/300%。\n"
"21点先用 /21点 开始 [1~500] 进场,金额是本局下注,默认50;空格可省略。\n"
"看到牌后再单独发送 /21点 要牌|停牌|状态|结束"
"(/blackjack hit|stand|status|end);"
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
"抽奖:5%返5倍、20%返2倍,其余无返还;彩票命中返500倍;"
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
"以上均含本金;虚拟金币不能充值、转账或兑换。"
)
@@ -69,18 +146,16 @@ class PlayerGames:
return self.rules()
if arguments.casefold() in {"帮助", "规则", "help", "rules"}:
return self.rules()
if kind == "game_history":
return self.game_history(row, arguments)
if kind == "lottery":
return self.group_lottery(row, arguments)
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 == "lottery":
match = re.fullmatch(r"(?:(\d{3})(?:\s+(\d{1,3}))?)?", arguments)
if match is None:
return "用法:/买彩票 [三位号码000~999] [2~100金币](/lottery),默认随机号码、10金币"
number = int(match[1]) if match[1] is not None else None
bet = int(match[2] or 10)
elif kind == "blackjack":
compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态"
start = BLACKJACK_START.fullmatch(compacted)
@@ -100,27 +175,48 @@ class PlayerGames:
"/21点 要牌 或 /21点 停牌"
)
return (
"用法:先 /21点 开始 [2~100](金额是本局下注,默认50);"
"用法:先 /21点 开始 [1~500](金额是本局下注,默认50);"
"开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略"
)
else:
bet = 0
if kind in {"prize_draw", "lottery"} or kind == "blackjack" and action == "start":
if not 2 <= bet <= MAX_BET:
return "下注必须为 2~100 的整数金币"
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 的整数金币"
person = self.ledger.resolve_person(
str(row["group_fingerprint"]),
str(row["sender"]),
)
player_label = self.display_label(person["display_name"])
def blackjack_reply(text: str) -> str:
return f"{player_label}:{text}"
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 execute(account: dict[str, Any]) -> str:
normalize_player_account(account)
expired = ""
game = account.get("game")
if game and (
type(game.get("index")) is not int
or int(game["index"]) < 1
):
game["index"] = next_game_index(account, "blackjack")
if game and time.time() - float(game["updated_at"]) >= GAME_TIMEOUT_SECONDS:
append_game_record(
account,
kind="blackjack",
index=int(game["index"]),
outcome="超时认输",
stake=int(game["bet"]),
payout=0,
summary="10分钟无有效操作,按认输结束",
)
account["game"] = None
account["losses"] += 1
expired = "上一局已超时,按认输结束;"
@@ -130,44 +226,285 @@ class PlayerGames:
f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。"
"首次赠送1000仅发一次;/游戏帮助 查看规则")
if kind == "blackjack" and action in {"hit", "stand", "status", "end"}:
return blackjack_reply(
expired + self.play_blackjack(account, action)
active = account.get("game")
index = int(active["index"]) if active else None
return player_reply(
expired + self.play_blackjack(account, action),
index,
)
if account.get("game"):
message = expired + "你已有一局21点,请先 /21点 停牌 或 /21点 结束"
return blackjack_reply(message) if kind == "blackjack" else message
return (
player_reply(message, int(account["game"]["index"]))
if kind == "blackjack"
else message
)
if account["balance"] < bet:
message = expired + f"金币不足:余额 {account['balance']},本次需要 {bet}"
return blackjack_reply(message) if kind == "blackjack" else message
return player_reply(message) if kind == "blackjack" else message
account["balance"] -= bet
if kind == "blackjack":
index = next_game_index(account, "blackjack")
deck = list(range(52))
self.rng.shuffle(deck)
game = {"bet": bet, "deck": deck[4:], "player": deck[:2],
"dealer": deck[2:4], "updated_at": time.time()}
"dealer": deck[2:4], "updated_at": time.time(),
"index": index}
account["game"] = game
if hand_value(game["player"]) == 21 or hand_value(game["dealer"]) == 21:
return blackjack_reply(
expired + self.finish_blackjack(account, natural=True)
return player_reply(
expired + self.finish_blackjack(account, natural=True),
index,
)
return blackjack_reply(expired + self.show_hand(game))
if kind == "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 "未中奖"
else:
selected = self.rng.randrange(1000) if number is None else number
winning = self.rng.randrange(1000)
payout = bet * 500 if selected == winning else 0
result = f"号码 {selected:03d};开奖号 {winning:03d};" + ("中奖" if payout else "未中奖")
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
return expired + f"{result};下注 {bet},返还 {payout},净变化 {payout - bet:+d};金币 {account['balance']}"
append_game_record(
account,
kind="prize_draw",
index=index,
outcome=result,
stake=bet,
payout=payout,
summary=f"抽奖结果 {result}",
)
return player_reply(
expired
+ f"{result};下注 {bet},返还 {payout},"
f"净变化 {payout - bet:+d};金币 {account['balance']}",
index,
)
return self.ledger.apply_player_action(
str(row["group_fingerprint"]), str(row["sender"]),
str(row["event_id"]), kind + ":" + arguments, execute)
@staticmethod
def parse_lottery_entry(
arguments: str,
) -> tuple[tuple[int, int, int], int] | None:
value = arguments.strip().casefold()
for prefix in ("报名", "join"):
if value.startswith(prefix):
value = value[len(prefix):].strip()
break
if not value:
return None
marked = re.fullmatch(
r"([0-9])\s*([0-9])\s*([0-9])"
r"(?:金额|下注|bet)(\d{1,3})",
re.sub(r"[,,、]", "", value),
)
stake: int
if marked is not None:
digits = tuple(int(marked[index]) for index in range(1, 4))
stake = int(marked[4])
else:
tokens = re.sub(r"[,,、]", " ", value).split()
digits: tuple[int, int, int]
if len(tokens) == 1 and tokens[0].isdigit():
compact = tokens[0]
if not 3 <= len(compact) <= 6:
return None
digits = tuple(int(item) for item in compact[:3])
stake = int(compact[3:] or DEFAULT_LOTTERY_BET)
elif (
len(tokens) == 2
and re.fullmatch(r"\d{3}", tokens[0])
and re.fullmatch(r"\d{1,3}", tokens[1])
):
digits = tuple(int(item) for item in tokens[0])
stake = int(tokens[1])
elif (
len(tokens) in {3, 4}
and all(re.fullmatch(r"\d", item) for item in tokens[:3])
and (
len(tokens) == 3
or re.fullmatch(r"\d{1,3}", tokens[3])
)
):
digits = tuple(int(item) for item in tokens[:3])
stake = (
int(tokens[3])
if len(tokens) == 4
else DEFAULT_LOTTERY_BET
)
else:
return None
if len(set(digits)) != 3:
raise ValueError("彩票的三个数字必须互不重复")
if not MIN_LOTTERY_BET <= stake <= MAX_LOTTERY_BET:
raise ValueError("彩票下注必须为 1~500 的整数金币")
return tuple(sorted(digits)), stake
def group_lottery(self, row: dict[str, Any], arguments: str) -> str:
group = str(row["group_fingerprint"])
sender = str(row["sender"])
event_id = str(row["event_id"])
command = "lottery:" + arguments
compacted = re.sub(r"\s+", "", arguments.casefold())
def status_text(data: dict[str, Any]) -> str:
if data["status"] == "no_round":
return "当前没有进行中的彩票;发送 /彩票 开始 创建新一把"
entries = data["entries"]
lines = [
f"彩票第 {data['round_number']} 把正在报名;"
f"主持人 {data['host_display_name']};已报名 {len(entries)} 人"
]
for entry in entries:
lines.append(
f"{entry['display_name']} #{entry['player_game_index']}:"
f"{' '.join(map(str, entry['numbers']))},下注 {entry['stake']}"
)
if not entries:
lines.append("暂无报名;/彩票 报名 3 4 7 [金额]")
return "\n".join(lines)
if compacted in {"开始", "start"}:
return self.ledger.start_group_lottery(
group,
sender,
event_id,
command,
lambda data: (
f"彩票第 {data['round_number']} 把"
+ ("已经在报名中" if data["status"] == "already_open" else "已开始")
+ f";主持人 {data['host_display_name']}。\n"
"报名:/彩票 3 4 7 [金额](金额1~500,默认10,空格可省略);"
"查询:/彩票 当前状态;主持人开奖:/彩票 开奖"
),
)
if compacted in {"当前状态", "状态", "status"}:
return self.ledger.group_lottery_status(
group,
sender,
event_id,
command,
status_text,
)
if compacted in {"开奖", "draw"}:
drawn = tuple(sorted(self.rng.sample(range(10), 3)))
def draw_text(data: dict[str, Any]) -> str:
if data["status"] == "no_round":
return "当前没有可以开奖的彩票"
if data["status"] == "not_host":
return (
f"只有本轮主持人 {data['host_display_name']} 可以开奖"
)
if data["status"] == "empty":
return "本轮还没有人报名,不能开奖"
lines = [
f"彩票第 {data['round_number']} 把开奖号:"
f"{' '.join(map(str, data['drawn_numbers']))}"
]
for entry in data["entries"]:
lines.append(
f"{entry['display_name']} #{entry['player_game_index']}:"
f"命中 {entry['matches']} 个;下注 {entry['stake']},"
f"返还 {entry['payout']},净变化 "
f"{entry['payout'] - entry['stake']:+d};"
f"金币 {entry['balance']}"
)
return "\n".join(lines)
return self.ledger.draw_group_lottery(
group,
sender,
event_id,
command,
drawn,
draw_text,
)
if compacted in {"取消", "cancel"}:
return self.ledger.cancel_group_lottery(
group,
sender,
event_id,
command,
lambda data: (
"当前没有进行中的彩票"
if data["status"] == "no_round"
else (
f"只有本轮主持人 {data['host_display_name']} 可以取消"
if data["status"] == "not_host"
else (
f"彩票第 {data['round_number']} 把已取消;"
f"{len(data['entries'])} 人的下注已全额退回"
)
)
),
)
try:
parsed = self.parse_lottery_entry(arguments)
except ValueError as error:
return str(error)
if parsed is None:
return (
"用法:先 /彩票 开始;再 /彩票 报名 3 4 7 [金额]"
"(也可 /彩票347,金额1~500、默认10);"
"/彩票 当前状态;主持人 /彩票 开奖"
)
numbers, stake = parsed
def register_text(data: dict[str, Any]) -> str:
if data["status"] == "no_round":
return "当前没有进行中的彩票;先发送 /彩票 开始"
if data["status"] == "insufficient":
return (
f"{data['display_name']}:金币不足,余额 {data['balance']},"
f"报名需要 {stake}"
)
action = "报名已更新" if data["status"] == "updated" else "报名成功"
return (
f"{data['display_name']} #{data['player_game_index']}:"
f"彩票第 {data['round_number']} 把{action};号码 "
f"{' '.join(map(str, data['numbers']))};下注 {data['stake']};"
f"金币 {data['balance']}"
)
return self.ledger.register_group_lottery(
group,
sender,
event_id,
command,
numbers,
stake,
register_text,
)
def game_history(self, row: dict[str, Any], arguments: str) -> str:
if arguments and not re.fullmatch(r"\d{1,3}", arguments):
return "用法:/游戏记录 [页码](/gamehistory),每页5条"
page = int(arguments or 1)
if page < 1:
return "游戏记录页码必须从 1 开始"
data = self.ledger.player_game_history(
str(row["group_fingerprint"]),
str(row["sender"]),
)
records = data["history"]
pages = max(1, (len(records) + GAME_HISTORY_PAGE_SIZE - 1) // GAME_HISTORY_PAGE_SIZE)
if page > pages:
return f"{data['display_name']} 只有 {pages} 页游戏记录"
start = (page - 1) * GAME_HISTORY_PAGE_SIZE
lines = [f"{data['display_name']} 的游戏记录 {page}/{pages}:"]
for record in records[start:start + GAME_HISTORY_PAGE_SIZE]:
label = GAME_LABELS.get(str(record["kind"]), str(record["kind"]))
lines.append(
f"{data['display_name']} #{record['index']}:{label} {record['outcome']};"
f"下注 {record['stake']},返还 {record['payout']},"
f"净变化 {record['payout'] - record['stake']:+d}"
)
if not records:
lines.append("暂无已结算记录")
return "\n".join(lines)
@staticmethod
def show_hand(game: dict[str, Any]) -> str:
player = " ".join(card_label(card) for card in game["player"])
@@ -182,6 +519,17 @@ class PlayerGames:
if action == "status":
return self.show_hand(game)
if action == "end":
index = int(game["index"])
bet = int(game["bet"])
append_game_record(
account,
kind="blackjack",
index=index,
outcome="主动认输",
stake=bet,
payout=0,
summary="主动结束牌局,不退下注",
)
account["game"] = None
account["losses"] += 1
return f"本人的21点已认输结束,不退下注;金币 {account['balance']}"
@@ -213,6 +561,17 @@ class PlayerGames:
outcome, payout, stat = "你输了", 0, "losses"
account["balance"] += payout
account[stat] += 1
append_game_record(
account,
kind="blackjack",
index=int(game["index"]),
outcome=outcome,
stake=int(bet),
payout=int(payout),
summary=(
f"玩家{player}点,庄家{dealer}点"
),
)
account["game"] = None
return (f"21点结算:{outcome};你的牌 {' '.join(map(card_label, game['player']))}({player}点),"
f"庄家 {' '.join(map(card_label, game['dealer']))}({dealer}点);"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-sdk"
version = "0.6.2a0+1015"
version = "0.6.3a0+1015"
description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9"
dependencies = []
@@ -38,4 +38,4 @@ __all__ = [
"SingleGroupContext",
]
__version__ = "0.6.2a0+1015"
__version__ = "0.6.3a0+1015"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot]
release = "alpha 0.6.2.1015"
release = "alpha 0.6.3.1015"
production_deployments_enabled_by_default = false
[tool.jwebot.workspace]
+8 -1
View File
@@ -110,7 +110,14 @@ class LocalExtensionInventoryTests(unittest.TestCase):
self.assertEqual(
combined,
{
"single-player-games": ("wallet", "prize_draw", "lottery", "blackjack", "game_rules"),
"single-player-games": (
"wallet",
"prize_draw",
"lottery",
"blackjack",
"game_history",
"game_rules",
),
"person-directory": ("person_directory", "person_info"),
"person-memory": (
"remember",
-4
View File
@@ -125,10 +125,6 @@ class Release061Tests(unittest.TestCase):
self.command("/draw 100")
self.assertIn("金币不足", self.command("/draw 2"))
self.assertEqual(self.state()["balance"], 0)
with mock.patch.object(self.local.players.rng, "randrange", return_value=123):
reply = self.command("/买彩票 123 10", sender="Bob")
self.assertIn("返还 5000", reply)
self.assertEqual(self.state("Bob")["balance"], 5990)
def test_blackjack_is_private_persistent_and_settles_exactly_once(self):
# Player 10+7, dealer 9+8: standing produces a tie.
+4 -4
View File
@@ -56,7 +56,7 @@ class Release062BlackjackTests(unittest.TestCase):
side_effect=self.hit_deck,
):
start = self.command("/21点开始100", event_id="compact-start")
self.assertTrue(start.startswith("alice:"))
self.assertTrue(start.startswith("alice #1:"))
self.assertIn("下注 100", start)
self.assertEqual(self.state()["balance"], 900)
self.assertEqual(len(self.state()["game"]["player"]), 2)
@@ -69,7 +69,7 @@ class Release062BlackjackTests(unittest.TestCase):
)
self.assertEqual(replay, first)
self.assertTrue(first.startswith("alice:"))
self.assertTrue(first.startswith("alice #1:"))
self.assertIn("13点", first)
self.assertEqual(self.state()["balance"], 900)
self.assertEqual(len(self.state()["game"]["player"]), 3)
@@ -92,7 +92,7 @@ class Release062BlackjackTests(unittest.TestCase):
started = self.command(command, sender=sender)
self.assertTrue(
started.startswith(
"alice:" if sender == "Alice" else "bob:"
"alice #1:" if sender == "Alice" else "bob #1:"
)
)
self.assertIn("下注 50", started)
@@ -109,7 +109,7 @@ class Release062BlackjackTests(unittest.TestCase):
):
reply = self.command("/21点开始")
self.assertTrue(reply.startswith("alice:"))
self.assertTrue(reply.startswith("alice #1:"))
self.assertIn("下注 50", reply)
self.assertEqual(self.state()["balance"], 950)
+252
View File
@@ -0,0 +1,252 @@
"""Behavioral coverage for multiplayer lottery and indexed game records."""
import json
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
from test_jwebot_edge import MODULE, config
class Release063GameTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.engine = MODULE.EdgeApplication(config(Path(self.temp.name)))
self.counter = 0
def tearDown(self):
self.engine.ledger.connection.close()
self.temp.cleanup()
def command(
self,
question,
*,
sender="Alice",
group="group-a",
event_id=None,
):
self.counter += 1
row = {
"question": question,
"sender": sender,
"group_fingerprint": group,
"event_id": event_id or f"test-{self.counter}",
}
return self.engine.local.handle(row, {})
def state(self, sender="Alice", group="group-a"):
key = self.engine.ledger.resolve_person(group, sender)["person_key"]
row = self.engine.ledger.connection.execute(
"""
SELECT state_json FROM player_accounts
WHERE group_fingerprint = ? AND person_key = ?
""",
(group, key),
).fetchone()
return json.loads(row[0])
def test_lottery_number_forms_sort_and_validate(self):
parse = self.engine.local.players.parse_lottery_entry
self.assertEqual(parse("347"), ((3, 4, 7), 10))
self.assertEqual(parse("743"), ((3, 4, 7), 10))
self.assertEqual(parse("报名 3 4 7 100"), ((3, 4, 7), 100))
self.assertEqual(parse("报名347100"), ((3, 4, 7), 100))
self.assertEqual(parse("743金额500"), ((3, 4, 7), 500))
with self.assertRaisesRegex(ValueError, "互不重复"):
parse("337")
with self.assertRaisesRegex(ValueError, "1~500"):
parse("347501")
def test_multiplayer_lottery_status_draw_and_payouts(self):
self.assertIn("第 1 把已开始", self.command("/彩票开始"))
self.assertIn("alice #1", self.command("/彩票347100"))
self.assertIn(
"bob #1",
self.command("/彩票 报名 9 3 7 200", sender="Bob"),
)
self.assertIn(
"carol #1",
self.command("/彩票 1 2 3 100", sender="Carol"),
)
self.assertIn(
"Dave #1",
self.command("/彩票045100", sender="Dave"),
)
status = self.command("/彩票当前状态", sender="Bob")
self.assertIn("已报名 4 人", status)
self.assertIn("alice #1:3 4 7,下注 100", status)
self.assertIn("bob #1:3 7 9,下注 200", status)
self.assertIn("只有本轮主持人 alice", self.command(
"/彩票开奖", sender="Bob"))
with mock.patch.object(
self.engine.local.players.rng,
"sample",
return_value=[7, 3, 9],
):
result = self.command("/彩票开奖", event_id="draw-round-1")
self.assertIn("开奖号:3 7 9", result)
self.assertIn("alice #1:命中 2 个", result)
self.assertIn("返还 120", result)
self.assertIn("bob #1:命中 3 个", result)
self.assertIn("返还 600", result)
self.assertIn("carol #1:命中 1 个", result)
self.assertIn("返还 50", result)
self.assertIn("Dave #1:命中 0 个", result)
self.assertEqual(self.state()["balance"], 1020)
self.assertEqual(self.state("Bob")["balance"], 1400)
self.assertEqual(self.state("Carol")["balance"], 950)
self.assertEqual(self.state("Dave")["balance"], 900)
self.assertIn("命中2个", self.command("/游戏记录"))
self.assertIn("命中3个", self.command(
"/个人记录", sender="Bob"))
self.assertIn("没有进行中", self.command("/彩票当前状态"))
def test_registration_update_keeps_index_and_cancel_refunds(self):
self.command("/彩票 开始")
first = self.command("/彩票 347 100")
updated = self.command("/彩票 743 200", sender="a1")
self.assertIn("alice #1", first)
self.assertIn("alice #1", updated)
self.assertIn("报名已更新", updated)
self.assertEqual(self.state()["balance"], 800)
status = self.command("/彩票 状态")
self.assertIn("已报名 1 人", status)
self.assertIn("3 4 7,下注 200", status)
self.assertIn("只有本轮主持人 alice", self.command(
"/彩票 取消", sender="Bob"))
self.assertIn("已全额退回", self.command("/彩票取消"))
self.assertEqual(self.state()["balance"], 1000)
self.assertIn("净变化 +0", self.command("/游戏记录"))
self.command("/彩票开始")
second = self.command("/彩票347")
self.assertIn("alice #2", second)
def test_concurrent_registration_replay_debits_once(self):
self.command("/彩票开始")
replies = []
errors = []
def register():
try:
replies.append(
self.command("/彩票347500", event_id="same-entry")
)
except BaseException as error:
errors.append(error)
threads = [threading.Thread(target=register) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertFalse(errors)
self.assertEqual(len(set(replies)), 1)
self.assertEqual(self.state()["balance"], 500)
self.assertIn("已报名 1 人", self.command("/彩票状态"))
def test_blackjack_uses_1_to_500_and_indexed_history(self):
def tied_deck(cards):
first = [9, 6, 8, 7]
cards[:] = first + [card for card in cards if card not in first]
with mock.patch.object(
self.engine.local.players.rng,
"shuffle",
side_effect=tied_deck,
):
started = self.command("/21点开始500")
self.assertTrue(started.startswith("alice #1:"))
self.assertEqual(self.state()["balance"], 500)
settled = self.command("/21点停牌")
self.assertTrue(settled.startswith("alice #1:"))
self.assertIn("平局", settled)
self.assertEqual(self.state()["balance"], 1000)
history = self.command("/游戏记录")
self.assertIn("alice #1:21点 平局", history)
self.assertIn("下注 500,返还 500", history)
self.assertIn("1~500", self.command("/21点开始501"))
def test_prize_draw_has_indexed_history(self):
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=99,
):
first = self.command("/抽奖10")
second = self.command("/抽奖10")
self.assertTrue(first.startswith("alice #1:"))
self.assertTrue(second.startswith("alice #2:"))
history = self.command("/gamehistory")
self.assertIn("alice #2:抽奖 未中奖", history)
self.assertIn("alice #1:抽奖 未中奖", history)
def test_open_round_and_history_survive_restart(self):
root = Path(self.temp.name)
self.command("/彩票开始")
self.command("/彩票347100")
self.engine.ledger.connection.close()
self.engine = MODULE.EdgeApplication(config(root))
self.assertIn("alice #1:3 4 7", self.command("/彩票状态"))
with mock.patch.object(
self.engine.local.players.rng,
"sample",
return_value=[3, 4, 7],
):
self.command("/彩票开奖")
self.engine.ledger.connection.close()
self.engine = MODULE.EdgeApplication(config(root))
history = self.command("/游戏记录")
self.assertIn("alice #1:彩票 命中3个", history)
self.assertEqual(self.state()["balance"], 1200)
def test_registration_rolls_back_when_response_building_fails(self):
ledger = self.engine.ledger
ledger.start_group_lottery(
"group-a",
"Alice",
"start",
"lottery:start",
lambda _: "started",
)
def fail(_):
raise RuntimeError("render failed")
with self.assertRaisesRegex(RuntimeError, "render failed"):
ledger.register_group_lottery(
"group-a",
"Alice",
"register",
"lottery:347100",
(3, 4, 7),
100,
fail,
)
account = ledger.connection.execute(
"SELECT COUNT(*) FROM player_accounts"
).fetchone()[0]
entries = ledger.connection.execute(
"SELECT COUNT(*) FROM group_lottery_entries"
).fetchone()[0]
actions = ledger.connection.execute(
"SELECT COUNT(*) FROM player_actions WHERE event_id = 'register'"
).fetchone()[0]
self.assertEqual((account, entries, actions), (0, 0, 0))
if __name__ == "__main__":
unittest.main()