Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f07ae653c7 | ||
|
|
0731e5cebb | ||
|
|
f7fedc54e4 |
@@ -1,5 +1,58 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
to the existing 18-second socket inactivity timeout.
|
||||||
|
- Stop reading as soon as the SSE `[DONE]` marker arrives instead of waiting
|
||||||
|
for the upstream connection to close.
|
||||||
|
- Prevent a trickling or unfinished upstream response from occupying both
|
||||||
|
generation workers for about 15 minutes and delaying later final replies.
|
||||||
|
- Preserve fast ACK delivery, three-attempt retry behavior, two generation
|
||||||
|
workers, and strict final-reply ordering.
|
||||||
|
- Treat `/21点 开始 100` as a 100-coin stake and accept OCR forms with missing
|
||||||
|
whitespace, such as `/21点开始100` and `/blackjackstart100`.
|
||||||
|
- Keep blackjack play as explicit steps: start and deal first, then hit or
|
||||||
|
stand in a separate command. Combined forms return this workflow without
|
||||||
|
opening a game or deducting funds.
|
||||||
|
- Prefix blackjack hands, status and settlement replies with the current
|
||||||
|
group-scoped player's configured display label, such as `jacky:...`.
|
||||||
|
|
||||||
|
## alpha 0.6.1.1015
|
||||||
|
|
||||||
|
- Add three reviewed plugins: local context summaries for 100/200/300 messages,
|
||||||
|
person and alias lookup, and single-player virtual coin games.
|
||||||
|
- Provide corresponding Chinese and English commands, including `/context`,
|
||||||
|
`/people`, `/person`, `/wallet`, `/draw`, `/lottery`, and `/blackjack`.
|
||||||
|
- Grant each group-scoped person 1000 virtual coins once; persist account state,
|
||||||
|
private blackjack decks, timeout/forfeit rules, and win/loss statistics.
|
||||||
|
- Commit funds, game state and event replay responses in one SQLite transaction;
|
||||||
|
retries never debit twice, and aliases share the canonical person's account.
|
||||||
|
- Reconcile the message image after a two-second idle wait so a missed final
|
||||||
|
XDamage notification does not strand an isolated message; drain buffered Xlib
|
||||||
|
events before waiting on the socket. Preserve visual deduplication and guards.
|
||||||
|
- Keep the generated feature list within the sender's 900-character boundary.
|
||||||
|
- Document local extractive-summary coverage, OCR identity limitations,
|
||||||
|
game payouts, persistence and rollback behavior.
|
||||||
|
|
||||||
## alpha 0.6.0.1015
|
## alpha 0.6.0.1015
|
||||||
|
|
||||||
- Replace repository-local and hardcoded deployment values with independent,
|
- Replace repository-local and hardcoded deployment values with independent,
|
||||||
|
|||||||
@@ -8,7 +8,18 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
|
|||||||
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
||||||
并通过带视觉确认的 GUI 发送链路回复。
|
并通过带视觉确认的 GUI 发送链路回复。
|
||||||
|
|
||||||
当前版本:`alpha 0.6.0.1015`。
|
当前版本:`alpha 0.6.3.1015`。
|
||||||
|
|
||||||
|
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
|
||||||
|
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
|
||||||
|
[0.6.1 插件说明](docs/local-plugins-0.6.1.md)。
|
||||||
|
0.6.2 为 DeepSeek 流式回复增加 30 秒总时限,避免未结束的上游连接长期
|
||||||
|
占满生成 worker;21 点开局金额明确作为本局下注,并兼容 OCR 丢失空格。
|
||||||
|
21 点牌面和结算以当前玩家展示名开头。快速 ACK、重试和严格顺序投递
|
||||||
|
规则保持不变。
|
||||||
|
0.6.3 将彩票改为群内多人轮次,加入报名、状态、主持人开奖和取消;
|
||||||
|
抽奖、彩票、21 点统一使用 `Jacky #N:...` 局号并支持 `/游戏记录`。
|
||||||
|
完整玩法见[多人彩票与游戏记录](docs/group-lottery-0.6.3.md)。
|
||||||
|
|
||||||
### 运行流程
|
### 运行流程
|
||||||
|
|
||||||
@@ -72,6 +83,13 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
/功能列表
|
/功能列表
|
||||||
|
/上下文 [100|200|300](/context)
|
||||||
|
/人物列表 [页码](/people);/人物 [名称或别名](/person)
|
||||||
|
/金币(/wallet);/抽奖 [金额](/draw)
|
||||||
|
/彩票 开始|报名|当前状态|开奖|取消(/lottery)
|
||||||
|
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
|
||||||
|
/游戏记录 [页码](/gamehistory)
|
||||||
|
/游戏帮助(/games)
|
||||||
/丢骰子 [NdM]
|
/丢骰子 [NdM]
|
||||||
/猜数字 开始 [上限]
|
/猜数字 开始 [上限]
|
||||||
/石头剪刀布 [开始|加入|出|状态|结算|结束]
|
/石头剪刀布 [开始|加入|出|状态|结算|结束]
|
||||||
@@ -240,7 +258,7 @@ visually confirmed GUI bridge.
|
|||||||
Current version:
|
Current version:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
alpha 0.6.0.1015
|
alpha 0.6.3.1015
|
||||||
```
|
```
|
||||||
|
|
||||||
## Runtime Flow
|
## Runtime Flow
|
||||||
@@ -337,6 +355,23 @@ the final separator shown below:
|
|||||||
/steam时长Dota2人=玩家名
|
/steam时长Dota2人=玩家名
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Version 0.6.1 adds local `/context 100|200|300` extractive summaries,
|
||||||
|
`/people` and `/person` lookup, and `/wallet`, `/draw`, `/lottery`, `/blackjack`
|
||||||
|
single-player virtual coin games. Chinese aliases share the same implementations.
|
||||||
|
Accounts receive 1000 virtual coins once; funds, game state and event replay
|
||||||
|
results commit together. OCR display aliases identify group-scoped people;
|
||||||
|
native WeChat account IDs are unavailable. See
|
||||||
|
[plugin rules and capture reconciliation](docs/local-plugins-0.6.1.md).
|
||||||
|
Version 0.6.2 adds a 30-second total deadline to direct DeepSeek streams so an
|
||||||
|
unfinished upstream response cannot occupy both generation workers for about
|
||||||
|
15 minutes. Blackjack start amounts are the current round's stake; compact OCR
|
||||||
|
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
|
Steam prefers private per-person SteamID mappings from the active profile and
|
||||||
uses the corresponding group display IDs and aliases. Without a private roster,
|
uses the corresponding group display IDs and aliases. Without a private roster,
|
||||||
it discovers the configured account's public friend list at runtime. No real
|
it discovers the configured account's public friend list at runtime. No real
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ error. They never fall through to DeepSeek or OpenClaw.
|
|||||||
|
|
||||||
The local command path is:
|
The local command path is:
|
||||||
|
|
||||||
1. XDamage reports a relevant message-region change.
|
1. XDamage reports a relevant message-region change, or a two-second idle
|
||||||
|
reconciliation capture detects a missed final drawing.
|
||||||
2. OpenCV segments incoming bubbles without running OCR over the whole screen.
|
2. OpenCV segments incoming bubbles without running OCR over the whole screen.
|
||||||
3. The OCR engine reuses exact visual-fingerprint matches from a bounded LRU.
|
3. The OCR engine reuses exact visual-fingerprint matches from a bounded LRU.
|
||||||
4. Only new or changed sender/body crops use RapidOCR.
|
4. Only new or changed sender/body crops use RapidOCR.
|
||||||
@@ -45,6 +46,10 @@ because each bubble position still produces its own `IncomingMessage`.
|
|||||||
Changed pixels force fresh OCR. The cache is bounded and periodically
|
Changed pixels force fresh OCR. The cache is bounded and periodically
|
||||||
revalidated.
|
revalidated.
|
||||||
|
|
||||||
|
For the three local plugins added in `alpha 0.6.1.1015`, including bilingual
|
||||||
|
commands, person identity limitations and atomic single-player game settlement,
|
||||||
|
see [local plugin rules](local-plugins-0.6.1.md).
|
||||||
|
|
||||||
## Bundled Plugin Registration
|
## Bundled Plugin Registration
|
||||||
|
|
||||||
Bundled command metadata has one source:
|
Bundled command metadata has one source:
|
||||||
|
|||||||
@@ -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` 事务中提交。重复事件不会
|
||||||
|
重复扣款、发奖或增加局号;进程重启后未开奖轮次和个人记录仍然保留。
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# alpha 0.6.1.1015 本地插件与消息抓取
|
||||||
|
|
||||||
|
本版本增加三个插件。全部由本机命令引擎执行,不调用 DeepSeek 或
|
||||||
|
OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,OCR 丢失
|
||||||
|
命令与参数间的空格时仍可解析。正常使用时仍需 @ 当前配置的机器人。
|
||||||
|
|
||||||
|
| 插件 | 中文指令 | 英文指令 |
|
||||||
|
|---|---|---|
|
||||||
|
| context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` |
|
||||||
|
| single-player-games | `/金币` | `/wallet` |
|
||||||
|
| single-player-games | `/抽奖 [金额]` | `/draw [金额]` |
|
||||||
|
| 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 [名称或别名]` |
|
||||||
|
|
||||||
|
命令所有权来自 `descriptors.py`,由同步工具生成三套受审核 SDK adapter。
|
||||||
|
每个 adapter 只提交命名 service-profile 请求,由 Kernel 执行数据库操作。
|
||||||
|
注册在启动期完成,运行期冻结。`/余额` 保持查询提供商余额;
|
||||||
|
游戏虚拟余额使用 `/金币`。
|
||||||
|
|
||||||
|
## 上下文总结
|
||||||
|
|
||||||
|
默认选择最近 100 条,也可选择 200 或 300。查询限定当前群,排除当前
|
||||||
|
请求事件,按观察时间和事件 ID 选出最近记录,再恢复时间顺序。
|
||||||
|
不足指定数量时明确报告实际数量;没有记录时返回空历史提示。
|
||||||
|
|
||||||
|
总结采用本地抽取式方法:统计参与人数、时间范围、重复话题,选出五条
|
||||||
|
具有代表性的内容并按时间排列。它保留原话,不生成未经原文支持的决定
|
||||||
|
或待办。覆盖范围是本机已经成功记录的文字消息,不包含图片、语音、
|
||||||
|
未加载的微信历史或本机没观察到的消息。所有历史只保存在本机私有状态。
|
||||||
|
独立的 `context_history` 保留短消息,避免复读排行的长度过滤影响最近
|
||||||
|
100/200/300 条计数;启动时从现有复读历史补入旧记录,查询时从 OCR
|
||||||
|
历史导入已识别发送人的文字。
|
||||||
|
|
||||||
|
## 人物与账户索引
|
||||||
|
|
||||||
|
当前 OCR 输入只有显示名,没有微信原生账号 ID。Kernel 将昵称或别名
|
||||||
|
解析到私有配置中的 canonical_name,再以“当前群+规范人物名”计算
|
||||||
|
person_key。相同人物的已配置别名共用金币和牌局,其他群使用独立账户。
|
||||||
|
没有配置的显示名使用独立临时身份;未识别到发送人的消息不能操作账户。
|
||||||
|
|
||||||
|
`/人物` 默认查看本人;指定参数时只查询已配置人物。输出包括展示名、
|
||||||
|
截短的群内索引、别名和经过安全过滤的基本属性,不返回 SteamID 或密钥。
|
||||||
|
`/人物列表` 每页五人。真实人物资料仍只存放于 `~/.config/jwebot/`,
|
||||||
|
仓库没有生产人物或映射文件。
|
||||||
|
|
||||||
|
昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域;
|
||||||
|
应优先追加别名而不是重命名。用户不能通过游戏命令指定他人的账户,
|
||||||
|
不能充值、转账、赠币或兑换真实资产。
|
||||||
|
|
||||||
|
## 虚拟金币与游戏管理
|
||||||
|
|
||||||
|
每个群内人物首次使用账户时一次性获得 1000 虚拟金币。再次查询、
|
||||||
|
重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和
|
||||||
|
21 点下注为 1~500;余额不足或参数非法不扣款。
|
||||||
|
|
||||||
|
抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。
|
||||||
|
alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关,
|
||||||
|
下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见
|
||||||
|
[多人彩票与游戏记录](group-lottery-0.6.3.md)。
|
||||||
|
|
||||||
|
21 点默认下注 50、范围 1~500。`/21点 开始 100` 表示拿 100 金币作为本局下注进场,
|
||||||
|
等价的 OCR 紧凑形式是 `/21点开始100`。开局会扣除下注并发两张牌,
|
||||||
|
然后根据牌面单独发送 `/21点 要牌` 或 `/21点 停牌`;开局与操作写在
|
||||||
|
同一条消息时不会扣款,而会返回分步提示。牌局使用洗牌后的单副 52 张牌,
|
||||||
|
牌堆、双方手牌、下注和最后操作时间持久化。A 可作为 1 或 11;
|
||||||
|
庄家软 17 停牌。开始后只公开一张庄家牌;要牌、停牌、认输和查询都
|
||||||
|
仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头,
|
||||||
|
例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、
|
||||||
|
账户、局号和游戏记录。
|
||||||
|
自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍,
|
||||||
|
平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔
|
||||||
|
抽奖或 21 点开局,多人彩票报名仍可独立进行。
|
||||||
|
|
||||||
|
十分钟无有效游戏操作的牌局在下一次账户操作时按认输结束;查询状态
|
||||||
|
不延长有效期。主动结束同样按认输处理,不退下注。其他人可以各自
|
||||||
|
独立玩,不能加入或结束你的牌局。旧的群游戏保持独立。
|
||||||
|
|
||||||
|
Kernel 用一个 `BEGIN IMMEDIATE` 事务同时保存:
|
||||||
|
|
||||||
|
1. `player_accounts` 中的金币、牌局和胜负统计;
|
||||||
|
2. `player_actions` 中的事件、命令及响应。
|
||||||
|
|
||||||
|
同一群中的同一 event_id 只结算一次,重试返回原响应。相同事件若变更
|
||||||
|
人物或命令则拒绝。事务失败全部回滚,避免扣款后丢失结果。新的独立
|
||||||
|
指令会创建新的游戏操作,即使文字相同也不会被当成重试。
|
||||||
|
数据库迁移仅新增表,旧版本可以忽略它们;运行文件回滚不会主动删除
|
||||||
|
游戏数据。使用协调部署器回滚整库时,以部署前已验证备份为恢复基线。
|
||||||
|
|
||||||
|
## 单条消息抓取修复
|
||||||
|
|
||||||
|
独立观察证据表明,新气泡完成绘制后有时没有对应的有效 XDamage
|
||||||
|
事件;先前截图仍是旧布局,后续消息的绘制才唤醒生产抓取。
|
||||||
|
|
||||||
|
保留 XDamage 快速路径,同时将无有效变化时的复核等待窗口缩短到两秒。
|
||||||
|
空闲超时后走相同截图、跟随最新消息、视觉哈希和缓冲去重流程。
|
||||||
|
画面没变化时不进入重复 OCR;最终绘制漏事件时也能补抓。等待 X socket
|
||||||
|
前先检查 Xlib 已缓冲事件,避免用户态已有事件却阻塞等待另一次通知。
|
||||||
|
|
||||||
|
新增 `source=reconcile` 遥测用于识别补抓路径。两秒是等待窗口,
|
||||||
|
不是端到端响应承诺;截图、OCR、生成和 GUI 投递仍有各自耗时。
|
||||||
|
短周期复核增加空闲截图开销,上线后需观测实际负载。
|
||||||
|
群指纹、窗口就绪、GUI 串行、视觉确认和投递去重规则保持有效。
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# single-player-games
|
||||||
|
|
||||||
|
Author: `jwebot`
|
||||||
|
|
||||||
|
虚拟金币账户、抽奖、群内多人彩票和单人21点
|
||||||
|
|
||||||
|
Help:
|
||||||
|
|
||||||
|
- `/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory`
|
||||||
|
- `/彩票 开始|报名|当前状态|开奖|取消;/lottery`
|
||||||
|
- `/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)`
|
||||||
|
- `/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end`
|
||||||
|
- `/游戏帮助;/games`
|
||||||
|
|
||||||
|
Registered commands:
|
||||||
|
|
||||||
|
- `/金币`
|
||||||
|
- `/wallet`
|
||||||
|
- `/抽奖`
|
||||||
|
- `/draw`
|
||||||
|
- `/彩票`
|
||||||
|
- `/买彩票`
|
||||||
|
- `/lottery`
|
||||||
|
- `/21点`
|
||||||
|
- `/二十一点`
|
||||||
|
- `/blackjack`
|
||||||
|
- `/游戏记录`
|
||||||
|
- `/个人记录`
|
||||||
|
- `/gamehistory`
|
||||||
|
- `/游戏帮助`
|
||||||
|
- `/games`
|
||||||
|
|
||||||
|
The adapter has no direct Ledger, GUI, environment, shell, or network access.
|
||||||
|
It delegates to the reviewed `single-player-games` Kernel service profile
|
||||||
|
and returns a structured `ReplyPlan`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
id = "single-player-games"
|
||||||
|
author = "jwebot"
|
||||||
|
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", "/游戏记录", "/个人记录", "/gamehistory", "/游戏帮助", "/games"]
|
||||||
|
storage = false
|
||||||
|
network_profiles = []
|
||||||
|
service_profiles = ["single-player-games"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Generated reviewed adapter for single-player-games."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'single-player-games'
|
||||||
|
AUTHOR = 'jwebot'
|
||||||
|
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):
|
||||||
|
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||||
|
response = context.services.call(
|
||||||
|
PROFILE,
|
||||||
|
operation="execute",
|
||||||
|
payload={
|
||||||
|
"command": command,
|
||||||
|
"arguments": arguments,
|
||||||
|
"event": event.to_mapping(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text = response.get("text")
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
raise ValueError("local service returned invalid text")
|
||||||
|
return ExtensionResult(
|
||||||
|
reply=ReplyPlan.text(text),
|
||||||
|
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||||
|
)
|
||||||
|
|
||||||
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
def plugin(context: SingleGroupContext) -> None:
|
||||||
|
for command in COMMANDS:
|
||||||
|
context.commands.register(command, _handler(context, command))
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE = Path(__file__).resolve().parents[1] / "src"
|
||||||
|
sys.path.insert(0, str(SOURCE))
|
||||||
|
|
||||||
|
from single_player_games import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
|
||||||
|
|
||||||
|
|
||||||
|
class PluginTests(unittest.TestCase):
|
||||||
|
def test_registers_declared_commands_and_author(self):
|
||||||
|
context = mock.Mock()
|
||||||
|
plugin(context)
|
||||||
|
registered = [
|
||||||
|
call.args[0]
|
||||||
|
for call in context.commands.register.call_args_list
|
||||||
|
]
|
||||||
|
self.assertEqual(AUTHOR, 'jwebot')
|
||||||
|
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), 15)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# context-summary
|
||||||
|
|
||||||
|
Author: `official`
|
||||||
|
|
||||||
|
提取当前群最近100、200或300条本地文字的上下文
|
||||||
|
|
||||||
|
Help:
|
||||||
|
|
||||||
|
- `/上下文 [100|200|300];/context [100|200|300]`
|
||||||
|
|
||||||
|
Registered commands:
|
||||||
|
|
||||||
|
- `/上下文`
|
||||||
|
- `/context`
|
||||||
|
|
||||||
|
The adapter has no direct Ledger, GUI, environment, shell, or network access.
|
||||||
|
It delegates to the reviewed `context-summary` Kernel service profile
|
||||||
|
and returns a structured `ReplyPlan`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
id = "context-summary"
|
||||||
|
author = "official"
|
||||||
|
description = "提取当前群最近100、200或300条本地文字的上下文"
|
||||||
|
help = ["/上下文 [100|200|300];/context [100|200|300]"]
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1"
|
||||||
|
entrypoint = "context_summary:plugin"
|
||||||
|
type = "command"
|
||||||
|
|
||||||
|
[capabilities]
|
||||||
|
commands = ["/上下文", "/context"]
|
||||||
|
storage = false
|
||||||
|
network_profiles = []
|
||||||
|
service_profiles = ["context-summary"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Generated reviewed adapter for context-summary."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'context-summary'
|
||||||
|
AUTHOR = 'official'
|
||||||
|
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文'
|
||||||
|
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
|
||||||
|
COMMANDS = ('/上下文', '/context')
|
||||||
|
|
||||||
|
|
||||||
|
def _handler(context: SingleGroupContext, command: str):
|
||||||
|
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||||
|
response = context.services.call(
|
||||||
|
PROFILE,
|
||||||
|
operation="execute",
|
||||||
|
payload={
|
||||||
|
"command": command,
|
||||||
|
"arguments": arguments,
|
||||||
|
"event": event.to_mapping(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text = response.get("text")
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
raise ValueError("local service returned invalid text")
|
||||||
|
return ExtensionResult(
|
||||||
|
reply=ReplyPlan.text(text),
|
||||||
|
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||||
|
)
|
||||||
|
|
||||||
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
def plugin(context: SingleGroupContext) -> None:
|
||||||
|
for command in COMMANDS:
|
||||||
|
context.commands.register(command, _handler(context, command))
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE = Path(__file__).resolve().parents[1] / "src"
|
||||||
|
sys.path.insert(0, str(SOURCE))
|
||||||
|
|
||||||
|
from context_summary import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
|
||||||
|
|
||||||
|
|
||||||
|
class PluginTests(unittest.TestCase):
|
||||||
|
def test_registers_declared_commands_and_author(self):
|
||||||
|
context = mock.Mock()
|
||||||
|
plugin(context)
|
||||||
|
registered = [
|
||||||
|
call.args[0]
|
||||||
|
for call in context.commands.register.call_args_list
|
||||||
|
]
|
||||||
|
self.assertEqual(AUTHOR, 'official')
|
||||||
|
self.assertEqual(DESCRIPTION, '提取当前群最近100、200或300条本地文字的上下文')
|
||||||
|
self.assertEqual(HELP_ENTRIES, ('/上下文 [100|200|300];/context [100|200|300]',))
|
||||||
|
self.assertIn('/上下文', registered)
|
||||||
|
self.assertEqual(len(registered), 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# person-directory
|
||||||
|
|
||||||
|
Author: `official`
|
||||||
|
|
||||||
|
查询当前群已配置人物、基本属性和别名
|
||||||
|
|
||||||
|
Help:
|
||||||
|
|
||||||
|
- `/人物列表 [页码] /people;/人物 [名称或别名] /person`
|
||||||
|
|
||||||
|
Registered commands:
|
||||||
|
|
||||||
|
- `/人物列表`
|
||||||
|
- `/别名列表`
|
||||||
|
- `/people`
|
||||||
|
- `/人物`
|
||||||
|
- `/个人属性`
|
||||||
|
- `/person`
|
||||||
|
|
||||||
|
The adapter has no direct Ledger, GUI, environment, shell, or network access.
|
||||||
|
It delegates to the reviewed `person-directory` Kernel service profile
|
||||||
|
and returns a structured `ReplyPlan`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
id = "person-directory"
|
||||||
|
author = "official"
|
||||||
|
description = "查询当前群已配置人物、基本属性和别名"
|
||||||
|
help = ["/人物列表 [页码] /people;/人物 [名称或别名] /person"]
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1"
|
||||||
|
entrypoint = "person_directory:plugin"
|
||||||
|
type = "command"
|
||||||
|
|
||||||
|
[capabilities]
|
||||||
|
commands = ["/人物列表", "/别名列表", "/people", "/人物", "/个人属性", "/person"]
|
||||||
|
storage = false
|
||||||
|
network_profiles = []
|
||||||
|
service_profiles = ["person-directory"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Generated reviewed adapter for person-directory."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'person-directory'
|
||||||
|
AUTHOR = 'official'
|
||||||
|
DESCRIPTION = '查询当前群已配置人物、基本属性和别名'
|
||||||
|
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
|
||||||
|
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
|
||||||
|
|
||||||
|
|
||||||
|
def _handler(context: SingleGroupContext, command: str):
|
||||||
|
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||||
|
response = context.services.call(
|
||||||
|
PROFILE,
|
||||||
|
operation="execute",
|
||||||
|
payload={
|
||||||
|
"command": command,
|
||||||
|
"arguments": arguments,
|
||||||
|
"event": event.to_mapping(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text = response.get("text")
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
raise ValueError("local service returned invalid text")
|
||||||
|
return ExtensionResult(
|
||||||
|
reply=ReplyPlan.text(text),
|
||||||
|
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||||
|
)
|
||||||
|
|
||||||
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
def plugin(context: SingleGroupContext) -> None:
|
||||||
|
for command in COMMANDS:
|
||||||
|
context.commands.register(command, _handler(context, command))
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE = Path(__file__).resolve().parents[1] / "src"
|
||||||
|
sys.path.insert(0, str(SOURCE))
|
||||||
|
|
||||||
|
from person_directory import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
|
||||||
|
|
||||||
|
|
||||||
|
class PluginTests(unittest.TestCase):
|
||||||
|
def test_registers_declared_commands_and_author(self):
|
||||||
|
context = mock.Mock()
|
||||||
|
plugin(context)
|
||||||
|
registered = [
|
||||||
|
call.args[0]
|
||||||
|
for call in context.commands.register.call_args_list
|
||||||
|
]
|
||||||
|
self.assertEqual(AUTHOR, 'official')
|
||||||
|
self.assertEqual(DESCRIPTION, '查询当前群已配置人物、基本属性和别名')
|
||||||
|
self.assertEqual(HELP_ENTRIES, ('/人物列表 [页码] /people;/人物 [名称或别名] /person',))
|
||||||
|
self.assertIn('/人物列表', registered)
|
||||||
|
self.assertEqual(len(registered), 6)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-config"
|
name = "jwebot-config"
|
||||||
version = "0.6.0a0+1015"
|
version = "0.6.3a0+1015"
|
||||||
description = "Profile-based configuration compiler for JWeBot"
|
description = "Profile-based configuration compiler for JWeBot"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ __all__ = [
|
|||||||
"write_default_profile",
|
"write_default_profile",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.0a0+1015"
|
__version__ = "0.6.3a0+1015"
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-extension-host"
|
name = "jwebot-extension-host"
|
||||||
version = "0.6.0a0+1015"
|
version = "0.6.3a0+1015"
|
||||||
description = "Single-release, single-invocation process host for JWeBot extensions"
|
description = "Single-release, single-invocation process host for JWeBot extensions"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jwebot-sdk==0.6.0a0+1015",
|
"jwebot-sdk==0.6.3a0+1015",
|
||||||
"jwebot-kernel==0.6.0a0+1015",
|
"jwebot-kernel==0.6.3a0+1015",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ __all__ = [
|
|||||||
"process_request",
|
"process_request",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.0a0+1015"
|
__version__ = "0.6.3a0+1015"
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-kernel"
|
name = "jwebot-kernel"
|
||||||
version = "0.6.0a0+1015"
|
version = "0.6.3a0+1015"
|
||||||
description = "Stable routing and extension authority for JWeBot"
|
description = "Stable routing and extension authority for JWeBot"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jwebot-config==0.6.0a0+1015",
|
"jwebot-config==0.6.3a0+1015",
|
||||||
"jwebot-sdk==0.6.0a0+1015",
|
"jwebot-sdk==0.6.3a0+1015",
|
||||||
"jwebot-official-extensions==0.6.0a0+1015",
|
"jwebot-official-extensions==0.6.3a0+1015",
|
||||||
"tomli>=2; python_version < '3.11'",
|
"tomli>=2; python_version < '3.11'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -107,4 +107,4 @@ __all__ = [
|
|||||||
"verify_installed_release_signature",
|
"verify_installed_release_signature",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.0a0+1015"
|
__version__ = "0.6.3a0+1015"
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ from jwebot_official_extensions.steam import (
|
|||||||
SteamClient,
|
SteamClient,
|
||||||
SteamUnavailable,
|
SteamUnavailable,
|
||||||
)
|
)
|
||||||
|
from jwebot_official_extensions.player_games import (
|
||||||
|
append_game_record,
|
||||||
|
next_game_index,
|
||||||
|
normalize_player_account,
|
||||||
|
)
|
||||||
from jwebot_sdk import Event, PlanKind
|
from jwebot_sdk import Event, PlanKind
|
||||||
|
|
||||||
|
|
||||||
@@ -92,6 +97,10 @@ MAX_REPLY_CHARS = 900
|
|||||||
MAX_CONTEXT_TURNS = 6
|
MAX_CONTEXT_TURNS = 6
|
||||||
MAX_OUTBOX_ATTEMPTS = 3
|
MAX_OUTBOX_ATTEMPTS = 3
|
||||||
DIRECT_CHAT_GENERATION_WORKERS = 2
|
DIRECT_CHAT_GENERATION_WORKERS = 2
|
||||||
|
DIRECT_API_SOCKET_TIMEOUT_SECONDS = 18.0
|
||||||
|
DIRECT_API_WALL_TIMEOUT_SECONDS = 30.0
|
||||||
|
DIRECT_API_MAX_RESPONSE_BYTES = 2_000_000
|
||||||
|
DIRECT_API_READ_CHUNK_BYTES = 65_536
|
||||||
FAST_ACKNOWLEDGEMENT = "喵......"
|
FAST_ACKNOWLEDGEMENT = "喵......"
|
||||||
PROFILE_MIN_AUTOMATIC_OBSERVATIONS = 3
|
PROFILE_MIN_AUTOMATIC_OBSERVATIONS = 3
|
||||||
PROFILE_MAX_FACTS = 12
|
PROFILE_MAX_FACTS = 12
|
||||||
@@ -626,6 +635,59 @@ def deepseek_response_content(raw: bytes) -> str:
|
|||||||
return str(content)
|
return str(content)
|
||||||
|
|
||||||
|
|
||||||
|
def read_direct_api_response(
|
||||||
|
response: Any,
|
||||||
|
*,
|
||||||
|
wall_timeout_seconds: float = DIRECT_API_WALL_TIMEOUT_SECONDS,
|
||||||
|
max_bytes: int = DIRECT_API_MAX_RESPONSE_BYTES,
|
||||||
|
) -> bytes:
|
||||||
|
"""Read an SSE response with an absolute deadline, not an idle timeout."""
|
||||||
|
|
||||||
|
deadline = time.monotonic() + wall_timeout_seconds
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
stream_tail = b""
|
||||||
|
done_marker = re.compile(
|
||||||
|
rb"(?:^|\r?\n)data:\s*\[DONE\]\s*(?:\r?\n|$)"
|
||||||
|
)
|
||||||
|
socket_connection = getattr(
|
||||||
|
getattr(getattr(response, "fp", None), "raw", None),
|
||||||
|
"_sock",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
read_chunk = getattr(response, "read1", response.read)
|
||||||
|
while True:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise DeepSeekUnavailable("direct API exceeded total timeout")
|
||||||
|
if socket_connection is not None:
|
||||||
|
socket_connection.settimeout(
|
||||||
|
max(
|
||||||
|
0.001,
|
||||||
|
min(DIRECT_API_SOCKET_TIMEOUT_SECONDS, remaining),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
chunk = read_chunk(
|
||||||
|
min(DIRECT_API_READ_CHUNK_BYTES, max_bytes - total + 1)
|
||||||
|
)
|
||||||
|
except (TimeoutError, OSError) as error:
|
||||||
|
raise DeepSeekUnavailable("direct API transport failure") from error
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
if not isinstance(chunk, bytes):
|
||||||
|
raise DeepSeekUnavailable("direct API returned invalid bytes")
|
||||||
|
total += len(chunk)
|
||||||
|
if total > max_bytes:
|
||||||
|
raise DeepSeekUnavailable("direct API response exceeded size limit")
|
||||||
|
chunks.append(chunk)
|
||||||
|
stream_scan = stream_tail + chunk
|
||||||
|
if done_marker.search(stream_scan):
|
||||||
|
break
|
||||||
|
stream_tail = stream_scan[-64:]
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
def load_environment_file(path: Path = ENVIRONMENT_FILE) -> None:
|
def load_environment_file(path: Path = ENVIRONMENT_FILE) -> None:
|
||||||
"""Load the active generated profile, then the legacy compatibility file."""
|
"""Load the active generated profile, then the legacy compatibility file."""
|
||||||
|
|
||||||
@@ -941,6 +1003,73 @@ class Ledger:
|
|||||||
observed_at TEXT NOT NULL,
|
observed_at TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS context_history (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
group_fingerprint TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
observed_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS context_history_group_time
|
||||||
|
ON context_history(group_fingerprint, observed_at, event_id);
|
||||||
|
INSERT OR IGNORE INTO context_history
|
||||||
|
SELECT event_id, group_fingerprint, display_name, text, observed_at
|
||||||
|
FROM repeat_history;
|
||||||
|
CREATE TABLE IF NOT EXISTS player_accounts (
|
||||||
|
group_fingerprint TEXT NOT NULL,
|
||||||
|
person_key TEXT NOT NULL,
|
||||||
|
state_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (group_fingerprint, person_key)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS player_actions (
|
||||||
|
group_fingerprint TEXT NOT NULL,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
person_key TEXT NOT NULL,
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
response_text TEXT NOT NULL,
|
||||||
|
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
|
CREATE INDEX IF NOT EXISTS events_group_created
|
||||||
ON events(group_fingerprint, created_at);
|
ON events(group_fingerprint, created_at);
|
||||||
CREATE INDEX IF NOT EXISTS outbox_due
|
CREATE INDEX IF NOT EXISTS outbox_due
|
||||||
@@ -2713,14 +2842,20 @@ class Ledger:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
content = bounded_text(text, limit=1_500)
|
content = bounded_text(text, limit=1_500)
|
||||||
repeat_key = repeat_text_key(content)
|
repeat_key = repeat_text_key(content)
|
||||||
|
if not event_id or not group_fingerprint or not content:
|
||||||
|
return False
|
||||||
|
person = self.resolve_person(group_fingerprint, sender)
|
||||||
|
with self.lock:
|
||||||
|
self.connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO context_history VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(event_id, group_fingerprint, person_display_label(person["display_name"]),
|
||||||
|
content, observed_at),
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
not event_id
|
not repeat_key
|
||||||
or not group_fingerprint
|
|
||||||
or not repeat_key
|
|
||||||
or len(repeat_key) < 2
|
or len(repeat_key) < 2
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
person = self.resolve_person(group_fingerprint, sender)
|
|
||||||
with self.lock, self.connection:
|
with self.lock, self.connection:
|
||||||
cursor = self.connection.execute(
|
cursor = self.connection.execute(
|
||||||
"""
|
"""
|
||||||
@@ -2846,6 +2981,659 @@ class Ledger:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def recent_context_messages(
|
||||||
|
self, group: str, limit: int, *, exclude_event_id: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if limit not in {100, 200, 300}:
|
||||||
|
raise LocalCommandError("上下文数量只支持 100、200、300")
|
||||||
|
with self.lock:
|
||||||
|
rows = self.connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT event_id, display_name, text, observed_at
|
||||||
|
FROM context_history
|
||||||
|
WHERE group_fingerprint = ? AND event_id != ?
|
||||||
|
ORDER BY observed_at DESC, event_id DESC LIMIT ?
|
||||||
|
""", (group, exclude_event_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in reversed(rows)]
|
||||||
|
|
||||||
|
def person_directory(self, group: str) -> list[dict[str, Any]]:
|
||||||
|
self.seed_person_aliases(group)
|
||||||
|
with self.lock:
|
||||||
|
rows = self.connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT canonical_name, alias_key, match_kind FROM person_aliases
|
||||||
|
WHERE group_fingerprint = ?
|
||||||
|
ORDER BY canonical_name, match_kind, alias_key
|
||||||
|
""", (group,),
|
||||||
|
).fetchall()
|
||||||
|
people: dict[str, dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
name = str(row["canonical_name"])
|
||||||
|
person = people.setdefault(name, {
|
||||||
|
"display_name": person_display_label(name),
|
||||||
|
"person_key": person_scope_key(group, name),
|
||||||
|
"aliases": [], "prefixes": [],
|
||||||
|
})
|
||||||
|
person["prefixes" if row["match_kind"] == "prefix" else "aliases"].append(
|
||||||
|
str(row["alias_key"]))
|
||||||
|
# Include profiles with a canonical name even when no alias was configured.
|
||||||
|
for template in PERSON_ALIAS_TEMPLATES:
|
||||||
|
name = str(template["canonical_name"])
|
||||||
|
people.setdefault(name, {
|
||||||
|
"display_name": person_display_label(name),
|
||||||
|
"person_key": person_scope_key(group, name),
|
||||||
|
"aliases": [], "prefixes": [],
|
||||||
|
})
|
||||||
|
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."""
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
except BaseException:
|
||||||
|
self.connection.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
|
||||||
def status(self) -> dict[str, Any]:
|
def status(self) -> dict[str, Any]:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
||||||
@@ -3292,8 +4080,11 @@ class DeepSeekClient:
|
|||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=18) as response:
|
with urllib.request.urlopen(
|
||||||
raw = response.read(2_000_000)
|
request,
|
||||||
|
timeout=DIRECT_API_SOCKET_TIMEOUT_SECONDS,
|
||||||
|
) as response:
|
||||||
|
raw = read_direct_api_response(response)
|
||||||
except urllib.error.HTTPError as error:
|
except urllib.error.HTTPError as error:
|
||||||
raise DeepSeekUnavailable(f"direct API HTTP {error.code}") from error
|
raise DeepSeekUnavailable(f"direct API HTTP {error.code}") from error
|
||||||
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-official-extensions"
|
name = "jwebot-official-extensions"
|
||||||
version = "0.6.0a0+1015"
|
version = "0.6.3a0+1015"
|
||||||
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jwebot-sdk==0.6.0a0+1015",
|
"jwebot-sdk==0.6.3a0+1015",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -62,4 +62,4 @@ __all__ = [
|
|||||||
"render_feature_list",
|
"render_feature_list",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.0a0+1015"
|
__version__ = "0.6.3a0+1015"
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
"""Generated reviewed adapter for context-summary."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'context-summary'
|
||||||
|
AUTHOR = 'official'
|
||||||
|
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文'
|
||||||
|
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
|
||||||
|
COMMANDS = ('/上下文', '/context')
|
||||||
|
|
||||||
|
|
||||||
|
def _handler(context: SingleGroupContext, command: str):
|
||||||
|
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||||
|
response = context.services.call(
|
||||||
|
PROFILE,
|
||||||
|
operation="execute",
|
||||||
|
payload={
|
||||||
|
"command": command,
|
||||||
|
"arguments": arguments,
|
||||||
|
"event": event.to_mapping(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text = response.get("text")
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
raise ValueError("local service returned invalid text")
|
||||||
|
return ExtensionResult(
|
||||||
|
reply=ReplyPlan.text(text),
|
||||||
|
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||||
|
)
|
||||||
|
|
||||||
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
def plugin(context: SingleGroupContext) -> None:
|
||||||
|
for command in COMMANDS:
|
||||||
|
context.commands.register(command, _handler(context, command))
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
"""Generated reviewed adapter for person-directory."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'person-directory'
|
||||||
|
AUTHOR = 'official'
|
||||||
|
DESCRIPTION = '查询当前群已配置人物、基本属性和别名'
|
||||||
|
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
|
||||||
|
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
|
||||||
|
|
||||||
|
|
||||||
|
def _handler(context: SingleGroupContext, command: str):
|
||||||
|
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||||
|
response = context.services.call(
|
||||||
|
PROFILE,
|
||||||
|
operation="execute",
|
||||||
|
payload={
|
||||||
|
"command": command,
|
||||||
|
"arguments": arguments,
|
||||||
|
"event": event.to_mapping(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text = response.get("text")
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
raise ValueError("local service returned invalid text")
|
||||||
|
return ExtensionResult(
|
||||||
|
reply=ReplyPlan.text(text),
|
||||||
|
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||||
|
)
|
||||||
|
|
||||||
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
def plugin(context: SingleGroupContext) -> None:
|
||||||
|
for command in COMMANDS:
|
||||||
|
context.commands.register(command, _handler(context, command))
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
"""Generated reviewed adapter for single-player-games."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'single-player-games'
|
||||||
|
AUTHOR = 'jwebot'
|
||||||
|
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):
|
||||||
|
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||||
|
response = context.services.call(
|
||||||
|
PROFILE,
|
||||||
|
operation="execute",
|
||||||
|
payload={
|
||||||
|
"command": command,
|
||||||
|
"arguments": arguments,
|
||||||
|
"event": event.to_mapping(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text = response.get("text")
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
raise ValueError("local service returned invalid text")
|
||||||
|
return ExtensionResult(
|
||||||
|
reply=ReplyPlan.text(text),
|
||||||
|
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||||
|
)
|
||||||
|
|
||||||
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
def plugin(context: SingleGroupContext) -> None:
|
||||||
|
for command in COMMANDS:
|
||||||
|
context.commands.register(command, _handler(context, command))
|
||||||
@@ -38,6 +38,29 @@ LOCAL_COMMAND_ALIASES = {
|
|||||||
"debug": "telemetry_debug",
|
"debug": "telemetry_debug",
|
||||||
"余额": "deepseek_balance",
|
"余额": "deepseek_balance",
|
||||||
"steam": "steam_friends",
|
"steam": "steam_friends",
|
||||||
|
"上下文": "context_summary",
|
||||||
|
"context": "context_summary",
|
||||||
|
"金币": "wallet",
|
||||||
|
"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",
|
||||||
|
"别名列表": "person_directory",
|
||||||
|
"people": "person_directory",
|
||||||
|
"人物": "person_info",
|
||||||
|
"个人属性": "person_info",
|
||||||
|
"person": "person_info",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +114,40 @@ DIRECT_KERNEL_COMMAND_KINDS = frozenset(
|
|||||||
)
|
)
|
||||||
|
|
||||||
LOCAL_EXTENSIONS = (
|
LOCAL_EXTENSIONS = (
|
||||||
|
LocalExtensionDescriptor(
|
||||||
|
extension_id="context-summary",
|
||||||
|
author=OFFICIAL_AUTHOR,
|
||||||
|
description="提取当前群最近100、200或300条本地文字的上下文",
|
||||||
|
help_entries=("/上下文 [100|200|300];/context [100|200|300]",),
|
||||||
|
command_kinds=("context_summary",),
|
||||||
|
),
|
||||||
|
LocalExtensionDescriptor(
|
||||||
|
extension_id="single-player-games",
|
||||||
|
author=JWEBOT_AUTHOR,
|
||||||
|
description="虚拟金币账户、抽奖、群内多人彩票和单人21点",
|
||||||
|
help_entries=(
|
||||||
|
"/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory",
|
||||||
|
"/彩票 开始|报名|当前状态|开奖|取消;/lottery",
|
||||||
|
"/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)",
|
||||||
|
"/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end",
|
||||||
|
"/游戏帮助;/games",
|
||||||
|
),
|
||||||
|
command_kinds=(
|
||||||
|
"wallet",
|
||||||
|
"prize_draw",
|
||||||
|
"lottery",
|
||||||
|
"blackjack",
|
||||||
|
"game_history",
|
||||||
|
"game_rules",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
LocalExtensionDescriptor(
|
||||||
|
extension_id="person-directory",
|
||||||
|
author=OFFICIAL_AUTHOR,
|
||||||
|
description="查询当前群已配置人物、基本属性和别名",
|
||||||
|
help_entries=("/人物列表 [页码] /people;/人物 [名称或别名] /person",),
|
||||||
|
command_kinds=("person_directory", "person_info"),
|
||||||
|
),
|
||||||
LocalExtensionDescriptor(
|
LocalExtensionDescriptor(
|
||||||
extension_id="person-memory",
|
extension_id="person-memory",
|
||||||
author=OFFICIAL_AUTHOR,
|
author=OFFICIAL_AUTHOR,
|
||||||
@@ -237,7 +294,6 @@ def render_feature_list() -> str:
|
|||||||
)
|
)
|
||||||
for descriptor in LOCAL_EXTENSIONS:
|
for descriptor in LOCAL_EXTENSIONS:
|
||||||
lines.append(
|
lines.append(
|
||||||
f"[{descriptor.author}/{descriptor.extension_id}] "
|
|
||||||
f"{descriptor.description}:"
|
f"{descriptor.description}:"
|
||||||
+ ";".join(descriptor.help_entries)
|
+ ";".join(descriptor.help_entries)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import json
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
from collections import Counter
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list
|
from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list
|
||||||
|
from .player_games import PlayerGames
|
||||||
|
|
||||||
|
|
||||||
LOCAL_GAME_TIMEOUT_SECONDS = 10 * 60
|
LOCAL_GAME_TIMEOUT_SECONDS = 10 * 60
|
||||||
@@ -104,6 +106,7 @@ class LocalCommandEngine:
|
|||||||
self.person_display_label = person_display_label
|
self.person_display_label = person_display_label
|
||||||
self.profile_fact_is_safe = profile_fact_is_safe
|
self.profile_fact_is_safe = profile_fact_is_safe
|
||||||
self.profile_expiry = profile_expiry
|
self.profile_expiry = profile_expiry
|
||||||
|
self.players = PlayerGames(ledger, person_display_label)
|
||||||
|
|
||||||
def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str:
|
def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str:
|
||||||
question = str(row["question"])
|
question = str(row["question"])
|
||||||
@@ -112,6 +115,19 @@ class LocalCommandEngine:
|
|||||||
raise LocalCommandError("本地命令不存在,发 /功能列表 查看可用本地功能")
|
raise LocalCommandError("本地命令不存在,发 /功能列表 查看可用本地功能")
|
||||||
if kind == "feature_list":
|
if kind == "feature_list":
|
||||||
return self.reply_cleaner(self.feature_list())
|
return self.reply_cleaner(self.feature_list())
|
||||||
|
if kind == "context_summary":
|
||||||
|
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_history",
|
||||||
|
"game_rules",
|
||||||
|
}:
|
||||||
|
return self.reply_cleaner(self.players.handle(row, kind, arguments))
|
||||||
if kind == "dice":
|
if kind == "dice":
|
||||||
return self.reply_cleaner(self.dice(row, arguments))
|
return self.reply_cleaner(self.dice(row, arguments))
|
||||||
if kind == "dice_challenge":
|
if kind == "dice_challenge":
|
||||||
@@ -229,6 +245,82 @@ class LocalCommandEngine:
|
|||||||
)
|
)
|
||||||
return f"本地记录命中“{query}”:{listing}"
|
return f"本地记录命中“{query}”:{listing}"
|
||||||
|
|
||||||
|
def context_summary(self, row: dict[str, Any], arguments: str) -> str:
|
||||||
|
arguments = arguments.strip()
|
||||||
|
if arguments.strip() not in {"", "100", "200", "300"}:
|
||||||
|
return "用法:/上下文 100|200|300(/context),默认100条"
|
||||||
|
limit = int(arguments or 100)
|
||||||
|
self.ledger.bootstrap_repeat_history()
|
||||||
|
records = self.ledger.recent_context_messages(
|
||||||
|
str(row["group_fingerprint"]), limit,
|
||||||
|
exclude_event_id=str(row["event_id"]))
|
||||||
|
if not records:
|
||||||
|
return "当前群还没有可总结的本地文字记录"
|
||||||
|
# Local extractive summary: rank representative messages, retaining chronology.
|
||||||
|
stop = {"这个", "那个", "就是", "然后", "现在", "我们", "你们", "可以",
|
||||||
|
"不是", "一个", "什么", "怎么", "感觉", "还是", "已经", "没有",
|
||||||
|
"the", "and", "you", "that", "this", "jwebot"}
|
||||||
|
texts = [re.sub(r"^@\S+\s+", "", normalize_text(record["text"])) for record in records]
|
||||||
|
tokens = [set(re.findall(r"[a-zA-Z][a-zA-Z0-9_-]{2,}|[\u4e00-\u9fff]{2}", text.casefold())) - stop
|
||||||
|
for text in texts]
|
||||||
|
frequency = Counter(token for message in tokens for token in message)
|
||||||
|
topics = [token for token, count in frequency.most_common(6) if count > 1]
|
||||||
|
scored = sorted(range(len(records)), key=lambda i: (
|
||||||
|
-sum(frequency[token] for token in tokens[i]),
|
||||||
|
-int(bool(re.search(r"决定|计划|问题|需要|建议|修复|明天|[??]", texts[i]))),
|
||||||
|
-i))
|
||||||
|
selected: list[int] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for index in scored:
|
||||||
|
if texts[index] in seen or texts[index].lstrip().startswith("/"):
|
||||||
|
continue
|
||||||
|
selected.append(index)
|
||||||
|
seen.add(texts[index])
|
||||||
|
if len(selected) == 5:
|
||||||
|
break
|
||||||
|
if not selected:
|
||||||
|
selected = list(range(max(0, len(records) - 3), len(records)))
|
||||||
|
people = len({record["display_name"] for record in records})
|
||||||
|
lines = [f"上下文总结(本地提取):请求最近{limit}条,实际{len(records)}条,{people}人。",
|
||||||
|
f"范围 {records[0]['observed_at'][:16]} ~ {records[-1]['observed_at'][:16]}。",
|
||||||
|
"主要话题:" + ("、".join(topics) if topics else "话题分散,以下为代表性内容。")]
|
||||||
|
for index in sorted(selected):
|
||||||
|
record = records[index]
|
||||||
|
lines.append(f"• {record['display_name']}:{texts[index][:100]}")
|
||||||
|
lines.append("仅覆盖本机已记录的当前群文字;问题或计划按原话提取。")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def person_index(self, row: dict[str, Any], kind: str, arguments: str) -> str:
|
||||||
|
group = str(row["group_fingerprint"])
|
||||||
|
directory = self.ledger.person_directory(group)
|
||||||
|
if kind == "person_directory":
|
||||||
|
if arguments and not re.fullmatch(r"[1-9]\d{0,2}", arguments):
|
||||||
|
return "用法:/人物列表 [页码](/people),每页5人"
|
||||||
|
page = int(arguments or 1)
|
||||||
|
pages = max(1, (len(directory) + 4) // 5)
|
||||||
|
if page > pages:
|
||||||
|
return f"只有 {pages} 页;/人物列表 1"
|
||||||
|
lines = [f"当前群人物与别名({page}/{pages},共{len(directory)}人)"]
|
||||||
|
for person in directory[(page - 1) * 5:page * 5]:
|
||||||
|
aliases = "、".join(person["aliases"])[:80] or "未配置"
|
||||||
|
prefixes = "、".join(person["prefixes"])[:40]
|
||||||
|
lines.append(f"{person['display_name']}:{aliases}" + (f";前缀 {prefixes}*" if prefixes else ""))
|
||||||
|
lines.append("/人物 [名称或别名](/person)查看基本属性;/人物列表 页码 翻页")
|
||||||
|
return "\n".join(lines)
|
||||||
|
requested = normalize_text(arguments) or str(row["sender"])
|
||||||
|
person = self.ledger.resolve_person(group, requested)
|
||||||
|
entry = next((item for item in directory if item["person_key"] == person["person_key"]), None)
|
||||||
|
if arguments and entry is None:
|
||||||
|
return "当前群没有这个已配置人物或别名;/人物列表 查看索引"
|
||||||
|
facts = str(person.get("identity_fact", ""))
|
||||||
|
if facts and not self.profile_fact_is_safe(facts):
|
||||||
|
facts = ""
|
||||||
|
aliases = "、".join(entry["aliases"])[:160] if entry else "未配置"
|
||||||
|
return (f"人物:{self.person_display_label(person['display_name'])};"
|
||||||
|
f"群内索引 {person['person_key'][:12]};别名:{aliases or '未配置'}。\n"
|
||||||
|
f"基本属性:{facts or '暂无配置'}。\n"
|
||||||
|
"本人金币 /金币;个人记忆 /我的记忆。身份由本地昵称映射解析,无法读取微信原生账号ID。")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def service_state(service_name: str) -> str:
|
def service_state(service_name: str) -> str:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,578 @@
|
|||||||
|
"""Single-player virtual currency rules, invoked by the reviewed Kernel service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
INITIAL_COINS = 1000
|
||||||
|
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",
|
||||||
|
"停牌": "stand",
|
||||||
|
"stand": "stand",
|
||||||
|
"状态": "status",
|
||||||
|
"status": "status",
|
||||||
|
"结束": "end",
|
||||||
|
"end": "end",
|
||||||
|
}
|
||||||
|
BLACKJACK_START = re.compile(
|
||||||
|
r"^(?:开始|start)(?P<bet>\d{1,3})?$"
|
||||||
|
)
|
||||||
|
BLACKJACK_COMBINED_ACTION = re.compile(
|
||||||
|
r"^(?:开始|start)(?P<bet>\d{1,3})?"
|
||||||
|
r"(?:要牌|hit|停牌|stand)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
def hand_value(cards: list[int]) -> int:
|
||||||
|
total = sum(min(card % 13 + 1, 10) if card % 13 else 11 for card in cards)
|
||||||
|
aces = sum(card % 13 == 0 for card in cards)
|
||||||
|
while total > 21 and aces:
|
||||||
|
total -= 10
|
||||||
|
aces -= 1
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerGames:
|
||||||
|
def __init__(self, ledger: Any, display_label: Any) -> None:
|
||||||
|
self.ledger = ledger
|
||||||
|
self.display_label = display_label
|
||||||
|
self.rng = random.SystemRandom()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def rules() -> str:
|
||||||
|
return (
|
||||||
|
"每人首次使用获 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"
|
||||||
|
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
|
||||||
|
"以上均含本金;虚拟金币不能充值、转账或兑换。"
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, row: dict[str, Any], kind: str, arguments: str) -> str:
|
||||||
|
arguments = arguments.strip()
|
||||||
|
if kind == "game_rules":
|
||||||
|
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 == "blackjack":
|
||||||
|
compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态"
|
||||||
|
start = BLACKJACK_START.fullmatch(compacted)
|
||||||
|
if start is not None:
|
||||||
|
action = "start"
|
||||||
|
bet = int(start["bet"] or 50)
|
||||||
|
else:
|
||||||
|
action = BLACKJACK_ACTIONS.get(compacted, "")
|
||||||
|
bet = 0
|
||||||
|
if not action:
|
||||||
|
combined = BLACKJACK_COMBINED_ACTION.fullmatch(compacted)
|
||||||
|
if combined is not None:
|
||||||
|
amount = combined["bet"] or "50"
|
||||||
|
return (
|
||||||
|
"21点要分两步:先发送 /21点 开始 "
|
||||||
|
f"{amount}({amount} 是本局下注);看到牌后再单独发送 "
|
||||||
|
"/21点 要牌 或 /21点 停牌"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"用法:先 /21点 开始 [1~500](金额是本局下注,默认50);"
|
||||||
|
"开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略"
|
||||||
|
)
|
||||||
|
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 的整数金币"
|
||||||
|
person = self.ledger.resolve_person(
|
||||||
|
str(row["group_fingerprint"]),
|
||||||
|
str(row["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 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 = "上一局已超时,按认输结束;"
|
||||||
|
if kind == "wallet":
|
||||||
|
state = "进行中" if account.get("game") else "无进行中的牌局"
|
||||||
|
return (f"{expired}金币 {account['balance']};21点 {state};"
|
||||||
|
f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。"
|
||||||
|
"首次赠送1000仅发一次;/游戏帮助 查看规则")
|
||||||
|
if kind == "blackjack" and action in {"hit", "stand", "status", "end"}:
|
||||||
|
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 (
|
||||||
|
player_reply(message, int(account["game"]["index"]))
|
||||||
|
if kind == "blackjack"
|
||||||
|
else message
|
||||||
|
)
|
||||||
|
if account["balance"] < bet:
|
||||||
|
message = expired + f"金币不足:余额 {account['balance']},本次需要 {bet}"
|
||||||
|
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(),
|
||||||
|
"index": index}
|
||||||
|
account["game"] = game
|
||||||
|
if hand_value(game["player"]) == 21 or hand_value(game["dealer"]) == 21:
|
||||||
|
return player_reply(
|
||||||
|
expired + self.finish_blackjack(account, natural=True),
|
||||||
|
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}",
|
||||||
|
)
|
||||||
|
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"])
|
||||||
|
return (f"21点:你的牌 {player}({hand_value(game['player'])}点);"
|
||||||
|
f"庄家 {card_label(game['dealer'][0])} + 暗牌;下注 {game['bet']}。\n"
|
||||||
|
"/21点 要牌(hit)或 /21点 停牌(stand)")
|
||||||
|
|
||||||
|
def play_blackjack(self, account: dict[str, Any], action: str) -> str:
|
||||||
|
game = account.get("game")
|
||||||
|
if not game:
|
||||||
|
return "本人没有进行中的21点;/21点 开始 [金额](/blackjack start)"
|
||||||
|
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']}"
|
||||||
|
game["updated_at"] = time.time()
|
||||||
|
if action == "hit":
|
||||||
|
game["player"].append(game["deck"].pop())
|
||||||
|
if hand_value(game["player"]) > 21:
|
||||||
|
return self.finish_blackjack(account)
|
||||||
|
return self.show_hand(game)
|
||||||
|
while hand_value(game["dealer"]) < 17:
|
||||||
|
game["dealer"].append(game["deck"].pop())
|
||||||
|
return self.finish_blackjack(account)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def finish_blackjack(account: dict[str, Any], *, natural: bool = False) -> str:
|
||||||
|
game = account["game"]
|
||||||
|
player = hand_value(game["player"])
|
||||||
|
dealer = hand_value(game["dealer"])
|
||||||
|
bet = game["bet"]
|
||||||
|
if player > 21 or natural and dealer == 21 and player != 21:
|
||||||
|
outcome, payout, stat = "你输了", 0, "losses"
|
||||||
|
elif player == dealer:
|
||||||
|
outcome, payout, stat = "平局", bet, "ties"
|
||||||
|
elif natural and player == 21:
|
||||||
|
outcome, payout, stat = "自然21点获胜", bet * 5 // 2, "wins"
|
||||||
|
elif dealer > 21 or player > dealer:
|
||||||
|
outcome, payout, stat = "你赢了", bet * 2, "wins"
|
||||||
|
else:
|
||||||
|
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}点);"
|
||||||
|
f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};金币 {account['balance']}")
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-sdk"
|
name = "jwebot-sdk"
|
||||||
version = "0.6.0a0+1015"
|
version = "0.6.3a0+1015"
|
||||||
description = "Typed, capability-limited extension API for JWeBot plugins"
|
description = "Typed, capability-limited extension API for JWeBot plugins"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
@@ -38,4 +38,4 @@ __all__ = [
|
|||||||
"SingleGroupContext",
|
"SingleGroupContext",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.0a0+1015"
|
__version__ = "0.6.3a0+1015"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[tool.jwebot]
|
[tool.jwebot]
|
||||||
release = "alpha 0.6.0.1015"
|
release = "alpha 0.6.3.1015"
|
||||||
production_deployments_enabled_by_default = false
|
production_deployments_enabled_by_default = false
|
||||||
|
|
||||||
[tool.jwebot.workspace]
|
[tool.jwebot.workspace]
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Lost final-draw events must not strand an isolated incoming message."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from test_wechat_local_ocr import MODULE
|
||||||
|
|
||||||
|
|
||||||
|
class ReconcileTests(unittest.TestCase):
|
||||||
|
def test_final_drawing_without_damage_is_captured_and_duplicates_are_suppressed(self):
|
||||||
|
class Monitor:
|
||||||
|
window_id = 42
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait_for_message_change(self):
|
||||||
|
return next(self.changes)
|
||||||
|
|
||||||
|
changes = iter((True, False, False))
|
||||||
|
|
||||||
|
buffer = MODULE.CaptureFrameBuffer(5)
|
||||||
|
outcomes = []
|
||||||
|
offer = buffer.offer
|
||||||
|
|
||||||
|
def observe(frame):
|
||||||
|
outcome = offer(frame)
|
||||||
|
outcomes.append(outcome)
|
||||||
|
if len(outcomes) == 3:
|
||||||
|
buffer.close()
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
images = [MODULE.Image.new("RGB", (2, 2), color) for color in ("white", "white", "black")]
|
||||||
|
recorder = MODULE.Recorder.__new__(MODULE.Recorder)
|
||||||
|
with mock.patch.object(MODULE, "XDamageMonitor", return_value=Monitor()), \
|
||||||
|
mock.patch.object(MODULE, "window_id", return_value="42"), \
|
||||||
|
mock.patch.object(MODULE, "capture_image", side_effect=images) as captures, \
|
||||||
|
mock.patch.object(MODULE, "message_view_at_loaded_bottom", return_value=True), \
|
||||||
|
mock.patch.object(MODULE, "message_image", side_effect=lambda image: image), \
|
||||||
|
mock.patch.object(MODULE, "incoming_frame_hashes", side_effect=[
|
||||||
|
("old-layout", "old-visual"), ("old-layout", "old-visual"), ("new-layout", "new-visual")]), \
|
||||||
|
mock.patch.object(buffer, "offer", side_effect=observe), \
|
||||||
|
mock.patch.object(MODULE, "telemetry_event") as telemetry:
|
||||||
|
recorder.capture_damage_frames(buffer)
|
||||||
|
self.assertEqual(captures.call_count, 3)
|
||||||
|
self.assertEqual(outcomes, ["enqueued", "duplicate", "enqueued"])
|
||||||
|
self.assertEqual(buffer.get().visual_hash, "old-visual")
|
||||||
|
self.assertEqual(buffer.get().visual_hash, "new-visual")
|
||||||
|
self.assertEqual([call.kwargs["source"] for call in telemetry.call_args_list
|
||||||
|
if call.args == ("ocr.capture",)], ["damage", "reconcile", "reconcile"])
|
||||||
|
self.assertLessEqual(MODULE.DAMAGE_HEARTBEAT_SECONDS, 2)
|
||||||
|
|
||||||
|
def test_reconciliation_does_not_bypass_window_identity_check(self):
|
||||||
|
class Monitor:
|
||||||
|
window_id = 42
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait_for_message_change(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
buffer = MODULE.CaptureFrameBuffer(2)
|
||||||
|
recorder = MODULE.Recorder.__new__(MODULE.Recorder)
|
||||||
|
with mock.patch.object(MODULE, "XDamageMonitor", return_value=Monitor()), \
|
||||||
|
mock.patch.object(MODULE, "window_id", return_value="43"), \
|
||||||
|
mock.patch.object(MODULE, "capture_image") as capture:
|
||||||
|
recorder.capture_damage_frames(buffer)
|
||||||
|
capture.assert_not_called()
|
||||||
|
with self.assertRaises(MODULE.CaptureProducerError):
|
||||||
|
buffer.get()
|
||||||
|
|
||||||
|
def test_xlib_buffered_event_is_drained_before_waiting_for_socket(self):
|
||||||
|
monitor = MODULE.XDamageMonitor.__new__(MODULE.XDamageMonitor)
|
||||||
|
monitor._display = object()
|
||||||
|
monitor._descriptor = 42
|
||||||
|
monitor._x11 = mock.Mock()
|
||||||
|
monitor._x11.XPending.side_effect = [1, 0]
|
||||||
|
with mock.patch.object(monitor, "_drain_events", return_value=True) as drain, \
|
||||||
|
mock.patch.object(MODULE.select, "select", return_value=([], [], [])) as select:
|
||||||
|
self.assertTrue(monitor.wait_for_message_change())
|
||||||
|
drain.assert_called_once()
|
||||||
|
select.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -704,7 +704,7 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
|||||||
self.assertEqual(response["route"], "local")
|
self.assertEqual(response["route"], "local")
|
||||||
self.assertEqual(response["execution_path"], "local-command")
|
self.assertEqual(response["execution_path"], "local-command")
|
||||||
self.assertIn("由内核和插件描述自动生成", response["text"])
|
self.assertIn("由内核和插件描述自动生成", response["text"])
|
||||||
self.assertIn("[jwebot/dice]", response["text"])
|
self.assertIn("/丢骰子", response["text"])
|
||||||
self.assertIn("骰子和多人骰子挑战", response["text"])
|
self.assertIn("骰子和多人骰子挑战", response["text"])
|
||||||
self.assertIn("/steam", response["text"])
|
self.assertIn("/steam", response["text"])
|
||||||
self.assertIn("[DeepSeek]", response["text"])
|
self.assertIn("[DeepSeek]", response["text"])
|
||||||
@@ -1736,7 +1736,7 @@ class DirectClientTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
response = mock.MagicMock()
|
response = mock.MagicMock()
|
||||||
response.read.return_value = (
|
response.read1.return_value = (
|
||||||
'data: {"choices":[{"delta":{"content":"正"}}]}\n\n'
|
'data: {"choices":[{"delta":{"content":"正"}}]}\n\n'
|
||||||
'data: {"choices":[{"delta":{"content":"常"}}]}\n\n'
|
'data: {"choices":[{"delta":{"content":"常"}}]}\n\n'
|
||||||
"data: [DONE]\n\n"
|
"data: [DONE]\n\n"
|
||||||
@@ -1782,6 +1782,55 @@ class DirectClientTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
temporary.cleanup()
|
temporary.cleanup()
|
||||||
|
|
||||||
|
def test_stream_reader_stops_at_done_without_waiting_for_eof(self):
|
||||||
|
response = mock.MagicMock()
|
||||||
|
response.read1.side_effect = [
|
||||||
|
b'data: {"choices":[{"delta":{"content":"ok"}}]}\n'
|
||||||
|
b"data: [DO",
|
||||||
|
b"NE]\n\n",
|
||||||
|
AssertionError("reader continued after the stream completed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
raw = MODULE.read_direct_api_response(response)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
raw,
|
||||||
|
b'data: {"choices":[{"delta":{"content":"ok"}}]}\n'
|
||||||
|
b"data: [DONE]\n\n",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.read1.call_count, 2)
|
||||||
|
|
||||||
|
def test_stream_reader_does_not_treat_content_as_done_marker(self):
|
||||||
|
response = mock.MagicMock()
|
||||||
|
response.read1.side_effect = [
|
||||||
|
b'data: {"choices":[{"delta":{"content":"data: [DONE]"}}]}\n\n',
|
||||||
|
b"data: [DONE]\n\n",
|
||||||
|
AssertionError("reader continued after the stream completed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
raw = MODULE.read_direct_api_response(response)
|
||||||
|
|
||||||
|
self.assertIn(b'"content":"data: [DONE]"', raw)
|
||||||
|
self.assertTrue(raw.endswith(b"data: [DONE]\n\n"))
|
||||||
|
self.assertEqual(response.read1.call_count, 2)
|
||||||
|
|
||||||
|
def test_stream_reader_enforces_total_wall_time(self):
|
||||||
|
response = mock.MagicMock()
|
||||||
|
response.read1.return_value = b": keep-alive\n\n"
|
||||||
|
with mock.patch.object(
|
||||||
|
MODULE.time,
|
||||||
|
"monotonic",
|
||||||
|
side_effect=[0.0, 0.0, 31.0],
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
MODULE.DeepSeekUnavailable,
|
||||||
|
"exceeded total timeout",
|
||||||
|
):
|
||||||
|
MODULE.read_direct_api_response(response)
|
||||||
|
|
||||||
|
response.fp.raw._sock.settimeout.assert_called_once_with(18.0)
|
||||||
|
response.read1.assert_called_once()
|
||||||
|
|
||||||
def test_stream_parser_accepts_a_non_streaming_compatibility_payload(self):
|
def test_stream_parser_accepts_a_non_streaming_compatibility_payload(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
MODULE.deepseek_response_content(
|
MODULE.deepseek_response_content(
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ class LocalExtensionInventoryTests(unittest.TestCase):
|
|||||||
with self.subTest(extension=extension.extension_id):
|
with self.subTest(extension=extension.extension_id):
|
||||||
self.assertTrue(extension.description)
|
self.assertTrue(extension.description)
|
||||||
self.assertTrue(extension.help_entries)
|
self.assertTrue(extension.help_entries)
|
||||||
self.assertIn(extension.extension_id, help_text)
|
|
||||||
self.assertIn(extension.description, help_text)
|
self.assertIn(extension.description, help_text)
|
||||||
for entry in extension.help_entries:
|
for entry in extension.help_entries:
|
||||||
self.assertIn(entry, help_text)
|
self.assertIn(entry, help_text)
|
||||||
@@ -81,6 +80,9 @@ class LocalExtensionInventoryTests(unittest.TestCase):
|
|||||||
for extension in LOCAL_EXTENSIONS
|
for extension in LOCAL_EXTENSIONS
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"context-summary": OFFICIAL_AUTHOR,
|
||||||
|
"person-directory": OFFICIAL_AUTHOR,
|
||||||
|
"single-player-games": JWEBOT_AUTHOR,
|
||||||
"person-memory": OFFICIAL_AUTHOR,
|
"person-memory": OFFICIAL_AUTHOR,
|
||||||
"server-status": OFFICIAL_AUTHOR,
|
"server-status": OFFICIAL_AUTHOR,
|
||||||
"jwebot-version": OFFICIAL_AUTHOR,
|
"jwebot-version": OFFICIAL_AUTHOR,
|
||||||
@@ -108,6 +110,15 @@ class LocalExtensionInventoryTests(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
combined,
|
combined,
|
||||||
{
|
{
|
||||||
|
"single-player-games": (
|
||||||
|
"wallet",
|
||||||
|
"prize_draw",
|
||||||
|
"lottery",
|
||||||
|
"blackjack",
|
||||||
|
"game_history",
|
||||||
|
"game_rules",
|
||||||
|
),
|
||||||
|
"person-directory": ("person_directory", "person_info"),
|
||||||
"person-memory": (
|
"person-memory": (
|
||||||
"remember",
|
"remember",
|
||||||
"my_memory",
|
"my_memory",
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Behavioral coverage for local context, identity and single-player funds."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from test_jwebot_edge import MODULE, config
|
||||||
|
from jwebot_official_extensions.local_commands import local_command_parts
|
||||||
|
from jwebot_official_extensions.player_games import hand_value
|
||||||
|
|
||||||
|
|
||||||
|
class Release061Tests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.engine = MODULE.EdgeApplication(config(self.root))
|
||||||
|
self.local = self.engine.local
|
||||||
|
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.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_aliases_are_corresponding_and_case_insensitive(self):
|
||||||
|
pairs = [("上下文", "Context"), ("金币", "wallet"), ("抽奖", "draw"),
|
||||||
|
("买彩票", "lottery"), ("21点", "blackjack"),
|
||||||
|
("人物列表", "people"), ("人物", "person"), ("游戏帮助", "games")]
|
||||||
|
for chinese, english in pairs:
|
||||||
|
self.assertEqual(local_command_parts("/" + chinese), local_command_parts("/" + english))
|
||||||
|
self.assertEqual(local_command_parts("/Context200"), ("context_summary", "200"))
|
||||||
|
|
||||||
|
def test_context_scope_count_order_and_current_request_exclusion(self):
|
||||||
|
ledger = self.engine.ledger
|
||||||
|
for i in range(305):
|
||||||
|
ledger.record_repeat_history(event_id=f"history-{i:04d}", group_fingerprint="group-a",
|
||||||
|
sender="Alice", text=f"项目计划讨论编号 {i}",
|
||||||
|
observed_at=f"2026-10-15T00:{i // 60:02d}:{i % 60:02d}+00:00")
|
||||||
|
ledger.record_repeat_history(event_id="private-other", group_fingerprint="group-b",
|
||||||
|
sender="Bob", text="隔离群秘密话题", observed_at="2026-10-15T01:00:00+00:00")
|
||||||
|
for count in (100, 200, 300):
|
||||||
|
rows = ledger.recent_context_messages("group-a", count, exclude_event_id="history-0304")
|
||||||
|
self.assertEqual(len(rows), count)
|
||||||
|
self.assertEqual(rows[-1]["event_id"], "history-0303")
|
||||||
|
self.assertLess(rows[0]["observed_at"], rows[-1]["observed_at"])
|
||||||
|
with mock.patch.object(ledger, "bootstrap_repeat_history"):
|
||||||
|
result = self.command(f"/context {count}")
|
||||||
|
self.assertIn(f"实际{count}条", result)
|
||||||
|
self.assertNotIn("隔离群", result)
|
||||||
|
self.assertIn("用法", self.command("/context 101"))
|
||||||
|
|
||||||
|
def test_person_lookup_uses_alias_but_does_not_expose_steam_ids(self):
|
||||||
|
self.assertIn("alice", self.command("/people"))
|
||||||
|
self.assertIn("Alice is a configured test profile", self.command("/person a1"))
|
||||||
|
self.assertIn("群内索引", self.command("/个人属性"))
|
||||||
|
self.assertIn("没有", self.command("/person unconfigured"))
|
||||||
|
self.assertIn("只有", self.command("/people 99"))
|
||||||
|
|
||||||
|
def test_context_keeps_short_messages_omitted_from_repeat_statistics(self):
|
||||||
|
ledger = self.engine.ledger
|
||||||
|
self.assertFalse(ledger.record_repeat_history(
|
||||||
|
event_id="short", group_fingerprint="group-a", sender="Alice",
|
||||||
|
text="好", observed_at="2026-10-15T00:00:00+00:00"))
|
||||||
|
records = ledger.recent_context_messages("group-a", 100)
|
||||||
|
self.assertEqual([row["text"] for row in records], ["好"])
|
||||||
|
self.assertEqual(ledger.search_repeat_history("group-a", "好"), [])
|
||||||
|
|
||||||
|
def test_initial_funds_aliases_groups_and_replay(self):
|
||||||
|
self.assertIn("金币 1000", self.command("/wallet", event_id="wallet-1"))
|
||||||
|
with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
|
||||||
|
first = self.command("/draw 10", event_id="draw-1")
|
||||||
|
replay = self.command("/draw 10", sender="a1", event_id="draw-1")
|
||||||
|
self.assertEqual(first, replay)
|
||||||
|
self.assertEqual(self.state("a1")["balance"], 990)
|
||||||
|
self.assertIn("金币 1000", self.command("/wallet", sender="Bob"))
|
||||||
|
self.assertIn("金币 1000", self.command("/wallet", group="group-b"))
|
||||||
|
self.engine.ledger.connection.close()
|
||||||
|
self.engine = MODULE.EdgeApplication(config(self.root))
|
||||||
|
self.local = self.engine.local
|
||||||
|
self.assertIn("金币 990", self.command("/wallet", event_id="restart"))
|
||||||
|
|
||||||
|
def test_concurrent_replay_debits_once_and_rolls_back_failure(self):
|
||||||
|
replies = []
|
||||||
|
with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
|
||||||
|
threads = [threading.Thread(target=lambda: replies.append(
|
||||||
|
self.command("/draw 100", event_id="concurrent"))) for _ in range(8)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
self.assertEqual(len(replies), 8)
|
||||||
|
self.assertEqual(len(set(replies)), 1)
|
||||||
|
self.assertEqual(self.state()["balance"], 900)
|
||||||
|
def broken(state):
|
||||||
|
state["balance"] = -1
|
||||||
|
return "bad"
|
||||||
|
with self.assertRaises(MODULE.LocalCommandError):
|
||||||
|
self.engine.ledger.apply_player_action("group-a", "Alice", "broken", "test", broken)
|
||||||
|
self.assertEqual(self.state()["balance"], 900)
|
||||||
|
with self.assertRaises(MODULE.LocalCommandError):
|
||||||
|
self.command("/draw 100", sender="Bob", event_id="concurrent")
|
||||||
|
|
||||||
|
def test_invalid_bets_insufficient_funds_and_lottery(self):
|
||||||
|
for question in ("/draw -1", "/draw 0", "/draw 101", "/lottery xyz",
|
||||||
|
"/blackjack start -1", "/blackjack hit 10"):
|
||||||
|
self.assertRegex(self.command(question), "用法|下注")
|
||||||
|
with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
|
||||||
|
for _ in range(10):
|
||||||
|
self.command("/draw 100")
|
||||||
|
self.assertIn("金币不足", self.command("/draw 2"))
|
||||||
|
self.assertEqual(self.state()["balance"], 0)
|
||||||
|
|
||||||
|
def test_blackjack_is_private_persistent_and_settles_exactly_once(self):
|
||||||
|
# Player 10+7, dealer 9+8: standing produces a tie.
|
||||||
|
def deck(cards):
|
||||||
|
ordered = [9, 6, 8, 7] + [card for card in cards if card not in {9, 6, 8, 7}]
|
||||||
|
cards[:] = ordered
|
||||||
|
with mock.patch.object(self.local.players.rng, "shuffle", side_effect=deck):
|
||||||
|
start = self.command("/21点 开始 50")
|
||||||
|
self.assertIn("暗牌", start)
|
||||||
|
self.assertEqual(self.state()["balance"], 950)
|
||||||
|
self.assertIn("没有进行中", self.command("/blackjack status", sender="Bob"))
|
||||||
|
self.assertIn("已有一局", self.command("/draw"))
|
||||||
|
self.engine.ledger.connection.close()
|
||||||
|
self.engine = MODULE.EdgeApplication(config(self.root))
|
||||||
|
self.local = self.engine.local
|
||||||
|
settled = self.command("/blackjack stand", sender="a1", event_id="stand-1")
|
||||||
|
self.assertIn("平局", settled)
|
||||||
|
self.assertEqual(self.state()["balance"], 1000)
|
||||||
|
self.assertEqual(self.command("/blackjack stand", event_id="stand-1"), settled)
|
||||||
|
self.assertIsNone(self.state()["game"])
|
||||||
|
self.assertEqual(self.state()["ties"], 1)
|
||||||
|
|
||||||
|
def test_aces_natural_bust_timeout_and_end(self):
|
||||||
|
self.assertEqual(hand_value([0, 13, 9]), 12)
|
||||||
|
self.assertEqual(hand_value([0, 9]), 21)
|
||||||
|
def natural(cards):
|
||||||
|
first = [0, 9, 8, 7]
|
||||||
|
cards[:] = first + [card for card in cards if card not in first]
|
||||||
|
with mock.patch.object(self.local.players.rng, "shuffle", side_effect=natural):
|
||||||
|
reply = self.command("/blackjack start 50")
|
||||||
|
self.assertIn("自然21点获胜", reply)
|
||||||
|
self.assertEqual(self.state()["balance"], 1075)
|
||||||
|
self.local.players.rng = random.Random(4)
|
||||||
|
self.command("/blackjack start 50")
|
||||||
|
if self.state()["game"]:
|
||||||
|
with mock.patch("jwebot_official_extensions.player_games.time.time", return_value=10**10):
|
||||||
|
self.assertIn("超时", self.command("/wallet"))
|
||||||
|
self.assertIsNone(self.state()["game"])
|
||||||
|
self.local.players.rng = random.Random(4)
|
||||||
|
self.command("/blackjack start 50")
|
||||||
|
if self.state()["game"]:
|
||||||
|
self.assertIn("不退下注", self.command("/blackjack end"))
|
||||||
|
self.assertIsNone(self.state()["game"])
|
||||||
|
|
||||||
|
def test_adapter_and_direct_paths_agree_for_new_plugins(self):
|
||||||
|
for index, question in enumerate(("/games", "/wallet", "/people", "/person a1", "/context 200")):
|
||||||
|
kind, _ = local_command_parts(question)
|
||||||
|
row = {"question": question, "sender": "Alice", "group_fingerprint": "group-a",
|
||||||
|
"event_id": f"adapter-{index}"}
|
||||||
|
with mock.patch.object(self.engine.ledger, "bootstrap_repeat_history"):
|
||||||
|
direct = self.local.handle(row, {})
|
||||||
|
adapter = self.engine.extensions.handle(row, {})
|
||||||
|
self.assertEqual(adapter, direct)
|
||||||
|
|
||||||
|
def test_blackjack_bust_cannot_be_acted_on_by_another_player(self):
|
||||||
|
def bust_deck(cards):
|
||||||
|
first = [9, 22, 8, 7]
|
||||||
|
cards[:] = first + [card for card in cards if card not in first and card != 35] + [35]
|
||||||
|
with mock.patch.object(self.local.players.rng, "shuffle", side_effect=bust_deck):
|
||||||
|
self.command("/blackjack start 100")
|
||||||
|
self.assertIn("没有进行中", self.command("/blackjack hit", sender="Bob"))
|
||||||
|
self.assertEqual(len(self.state()["game"]["player"]), 2)
|
||||||
|
result = self.command("/blackjack hit", event_id="bust")
|
||||||
|
self.assertIn("你输了", result)
|
||||||
|
self.assertEqual(self.state()["balance"], 900)
|
||||||
|
self.assertIsNone(self.state()["game"])
|
||||||
|
self.assertEqual(self.command("/blackjack hit", event_id="bust"), result)
|
||||||
|
self.assertEqual(self.state()["losses"], 1)
|
||||||
|
|
||||||
|
def test_simultaneous_natural_blackjack_returns_stake(self):
|
||||||
|
def tied_deck(cards):
|
||||||
|
first = [0, 9, 13, 22]
|
||||||
|
cards[:] = first + [card for card in cards if card not in first]
|
||||||
|
with mock.patch.object(self.local.players.rng, "shuffle", side_effect=tied_deck):
|
||||||
|
self.assertIn("平局", self.command("/blackjack start 50"))
|
||||||
|
self.assertEqual(self.state()["balance"], 1000)
|
||||||
|
self.assertIsNone(self.state()["game"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Behavioral coverage for the alpha 0.6.2 latency and blackjack fixes."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from test_jwebot_edge import MODULE, config
|
||||||
|
|
||||||
|
|
||||||
|
class Release062BlackjackTests(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, *, event_id=None, sender="Alice"):
|
||||||
|
self.counter += 1
|
||||||
|
row = {
|
||||||
|
"question": question,
|
||||||
|
"sender": sender,
|
||||||
|
"group_fingerprint": "group-a",
|
||||||
|
"event_id": event_id or f"test-{self.counter}",
|
||||||
|
}
|
||||||
|
return self.engine.local.handle(row, {})
|
||||||
|
|
||||||
|
def state(self, sender="Alice"):
|
||||||
|
key = self.engine.ledger.resolve_person("group-a", sender)["person_key"]
|
||||||
|
row = self.engine.ledger.connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT state_json FROM player_accounts
|
||||||
|
WHERE group_fingerprint = ? AND person_key = ?
|
||||||
|
""",
|
||||||
|
("group-a", key),
|
||||||
|
).fetchone()
|
||||||
|
return json.loads(row[0])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hit_deck(cards):
|
||||||
|
first = [4, 5, 8, 7]
|
||||||
|
cards[:] = (
|
||||||
|
first
|
||||||
|
+ [card for card in cards if card not in first and card != 1]
|
||||||
|
+ [1]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_compact_start_sets_stake_then_separate_hit_replays_once(self):
|
||||||
|
with mock.patch.object(
|
||||||
|
self.engine.local.players.rng,
|
||||||
|
"shuffle",
|
||||||
|
side_effect=self.hit_deck,
|
||||||
|
):
|
||||||
|
start = self.command("/21点开始100", event_id="compact-start")
|
||||||
|
self.assertTrue(start.startswith("alice #1:"))
|
||||||
|
self.assertIn("下注 100", start)
|
||||||
|
self.assertEqual(self.state()["balance"], 900)
|
||||||
|
self.assertEqual(len(self.state()["game"]["player"]), 2)
|
||||||
|
|
||||||
|
first = self.command("/21点要牌", event_id="separate-hit")
|
||||||
|
replay = self.command(
|
||||||
|
"/21点 要牌",
|
||||||
|
event_id="separate-hit",
|
||||||
|
sender="a1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(replay, first)
|
||||||
|
self.assertTrue(first.startswith("alice #1:"))
|
||||||
|
self.assertIn("13点", first)
|
||||||
|
self.assertEqual(self.state()["balance"], 900)
|
||||||
|
self.assertEqual(len(self.state()["game"]["player"]), 3)
|
||||||
|
|
||||||
|
def test_spaced_chinese_and_compact_english_start_use_bet(self):
|
||||||
|
def tied_deck(cards):
|
||||||
|
first = [9, 6, 8, 7]
|
||||||
|
cards[:] = first + [card for card in cards if card not in first]
|
||||||
|
|
||||||
|
for command, sender in (
|
||||||
|
("/21点 开始 50", "Alice"),
|
||||||
|
("/blackjackstart50", "Bob"),
|
||||||
|
):
|
||||||
|
with self.subTest(command=command):
|
||||||
|
with mock.patch.object(
|
||||||
|
self.engine.local.players.rng,
|
||||||
|
"shuffle",
|
||||||
|
side_effect=tied_deck,
|
||||||
|
):
|
||||||
|
started = self.command(command, sender=sender)
|
||||||
|
self.assertTrue(
|
||||||
|
started.startswith(
|
||||||
|
"alice #1:" if sender == "Alice" else "bob #1:"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn("下注 50", started)
|
||||||
|
self.assertEqual(self.state(sender)["balance"], 950)
|
||||||
|
self.assertIsNotNone(self.state(sender)["game"])
|
||||||
|
self.command("/21点 结束", sender=sender)
|
||||||
|
self.assertIsNone(self.state(sender)["game"])
|
||||||
|
|
||||||
|
def test_default_bet_can_start_without_spaces(self):
|
||||||
|
with mock.patch.object(
|
||||||
|
self.engine.local.players.rng,
|
||||||
|
"shuffle",
|
||||||
|
side_effect=self.hit_deck,
|
||||||
|
):
|
||||||
|
reply = self.command("/21点开始")
|
||||||
|
|
||||||
|
self.assertTrue(reply.startswith("alice #1:"))
|
||||||
|
self.assertIn("下注 50", reply)
|
||||||
|
self.assertEqual(self.state()["balance"], 950)
|
||||||
|
|
||||||
|
def test_combined_start_and_action_explains_two_step_flow(self):
|
||||||
|
reply = self.command("/21点开始100要牌")
|
||||||
|
|
||||||
|
self.assertIn("分两步", reply)
|
||||||
|
self.assertIn("100 是本局下注", reply)
|
||||||
|
self.assertIn("再单独发送", reply)
|
||||||
|
wallet = self.command("/金币")
|
||||||
|
self.assertIn("金币 1000", wallet)
|
||||||
|
self.assertIn("无进行中的牌局", wallet)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
+11
-9
@@ -63,7 +63,7 @@ POLL_SECONDS = 1.0
|
|||||||
STABLE_FRAMES = 2
|
STABLE_FRAMES = 2
|
||||||
DAMAGE_DEBOUNCE_SECONDS = 0.35
|
DAMAGE_DEBOUNCE_SECONDS = 0.35
|
||||||
DAMAGE_MAX_BURST_SECONDS = 1.0
|
DAMAGE_MAX_BURST_SECONDS = 1.0
|
||||||
DAMAGE_HEARTBEAT_SECONDS = 60.0
|
DAMAGE_HEARTBEAT_SECONDS = 2.0
|
||||||
CAPTURE_BUFFER_FRAMES = 12
|
CAPTURE_BUFFER_FRAMES = 12
|
||||||
OCR_MESSAGE_CACHE_SIZE = 256
|
OCR_MESSAGE_CACHE_SIZE = 256
|
||||||
OCR_CACHE_REFRESH_SCANS = 120
|
OCR_CACHE_REFRESH_SCANS = 120
|
||||||
@@ -1060,6 +1060,10 @@ class XDamageMonitor:
|
|||||||
timeout = max(0.0, deadline - now)
|
timeout = max(0.0, deadline - now)
|
||||||
if timeout == 0:
|
if timeout == 0:
|
||||||
return first_relevant is not None
|
return first_relevant is not None
|
||||||
|
# Xlib may already hold events even when its socket is no longer readable.
|
||||||
|
if self._x11.XPending(self._display):
|
||||||
|
readable = [self._descriptor]
|
||||||
|
else:
|
||||||
readable, _, _ = select.select([self._descriptor], [], [], timeout)
|
readable, _, _ = select.select([self._descriptor], [], [], timeout)
|
||||||
if not readable:
|
if not readable:
|
||||||
return first_relevant is not None
|
return first_relevant is not None
|
||||||
@@ -1728,10 +1732,8 @@ class Recorder:
|
|||||||
buffer.capacity,
|
buffer.capacity,
|
||||||
)
|
)
|
||||||
while not buffer.closed:
|
while not buffer.closed:
|
||||||
if not monitor.wait_for_message_change():
|
changed = monitor.wait_for_message_change()
|
||||||
if int(window_id()) != monitor.window_id:
|
source = "damage" if changed else "reconcile"
|
||||||
raise RuntimeError("WeChat window changed")
|
|
||||||
continue
|
|
||||||
identifier = window_id()
|
identifier = window_id()
|
||||||
if int(identifier) != monitor.window_id:
|
if int(identifier) != monitor.window_id:
|
||||||
raise RuntimeError("WeChat window changed")
|
raise RuntimeError("WeChat window changed")
|
||||||
@@ -1746,7 +1748,7 @@ class Recorder:
|
|||||||
* 1000,
|
* 1000,
|
||||||
outcome="captured",
|
outcome="captured",
|
||||||
phase="initial_capture",
|
phase="initial_capture",
|
||||||
source="damage",
|
source=source,
|
||||||
)
|
)
|
||||||
latest_synced = message_view_at_loaded_bottom(image)
|
latest_synced = message_view_at_loaded_bottom(image)
|
||||||
if not latest_synced:
|
if not latest_synced:
|
||||||
@@ -1757,7 +1759,7 @@ class Recorder:
|
|||||||
elapsed_ms=(time.monotonic() - follow_started) * 1000,
|
elapsed_ms=(time.monotonic() - follow_started) * 1000,
|
||||||
outcome="followed" if followed else "lock_busy",
|
outcome="followed" if followed else "lock_busy",
|
||||||
phase="follow_latest",
|
phase="follow_latest",
|
||||||
source="damage",
|
source=source,
|
||||||
)
|
)
|
||||||
if followed:
|
if followed:
|
||||||
recapture_started = time.monotonic()
|
recapture_started = time.monotonic()
|
||||||
@@ -1770,7 +1772,7 @@ class Recorder:
|
|||||||
* 1000,
|
* 1000,
|
||||||
outcome="captured",
|
outcome="captured",
|
||||||
phase="recapture",
|
phase="recapture",
|
||||||
source="damage",
|
source=source,
|
||||||
)
|
)
|
||||||
latest_synced = message_view_at_loaded_bottom(image)
|
latest_synced = message_view_at_loaded_bottom(image)
|
||||||
crop = message_image(image)
|
crop = message_image(image)
|
||||||
@@ -1801,7 +1803,7 @@ class Recorder:
|
|||||||
elapsed_ms=(time.monotonic() - capture_started) * 1000,
|
elapsed_ms=(time.monotonic() - capture_started) * 1000,
|
||||||
outcome=outcome,
|
outcome=outcome,
|
||||||
depth=stats["depth"],
|
depth=stats["depth"],
|
||||||
source="damage",
|
source=source,
|
||||||
)
|
)
|
||||||
if outcome == "closed":
|
if outcome == "closed":
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user