cortico-world-doudizhu 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,49 +1,65 @@
1
- # 更新日志
2
-
3
- ## 0.2.1
4
-
5
- - **每日自动签到领欢乐豆**:连上服务端 3 秒后查一次签到状态,没签就签(服务端固定 100 豆/天);
6
- 之后每 30 分钟复查,跨零点自动补签,已签不会重复请求(日志事件 `checkin` / `checkin_already` / `checkin_skipped`)。
7
- 新增 `ddz {action:"checkin"}` 可手动查/签;`state` 里多了 `beans`(欢乐豆)与 `checkinDate`。
8
- - **修复抢地主阶段判定**:服务端把抢地主阶段叫 `grab`(不是 `rob`),之前被当成叫分轮处理、
9
- 一律提交 `pass_bid`;现在 `rob|grab` 都按抢地主处理(强牌抢、弱牌不抢)。
10
- - **简介补充参与方式**:主人可在官网参与对局、观战(`/watch`)、点「插手」接管出牌;QQ 群 483590438。
11
- - 面板错误提示不再常驻:一次拉取超时后,下一次成功就自动清掉红字(并 5 秒后自动重试)。
12
-
13
- ## 0.2.0
14
-
15
- - **时限对齐服务端 v2.0.2-772mu.1**(官方口径:出牌 30 秒、桌宠叫分 20 秒、看门狗 30~32 秒触发):
16
- `fastMoveTimeoutMs` 24000、`decisionDeadlineMs` 28000,都压在 30 秒之前,本地兜底不会再比服务端晚。
17
- (之前误按 40 秒设成 32000/38000,当地兜底永远轮不到。)
18
- - **叫分本地判定(新增)**:叫分阶段(桌宠 20 秒)不再干等服务端托管——按手牌强度直接表态:
19
- 双王/双 2/炸弹/多 A 计分,叫分轮 score≥9 叫地主、否则不叫;抢地主轮 score≥14 抢、否则不抢。
20
- 不问模型、不推事件,决策写进 `actions.jsonl` 的 `fast_bid`(带 score / bidPhase)。
21
- - **总开关断开时机**:关掉后,正在打的局**打完再断**(服务端规则:断线 30 秒不回来就解散房间,中途硬断会拆别人的局);
22
- 不在牌局中(waiting / 已结算)时**5 秒内断开**(巡检从 30 秒改成 5 秒),配置热读、不用重启。
23
-
24
- - **快决策(fastMove)**:轮到自己时不再把整张牌桌推进主会话,而是用 ~300 token 的小提示词直接问一次主模型
25
- (手牌 / 上一手 / 两家剩几张 / 候选列表),拿到一手后**逐个对回服务端给的 legalActions** 再提交。
26
- 成功即 `POST /api/match/<table>/act`,不推事件、不抢占对话轮。实测中位 2.0~2.9 秒一手。
27
- - 模型爱用中文花色(`♠6 ♦6`)而不是 ASCII,解析层同时认 `♠6` / `6S` / `小王` / `SJ`;
28
- - 一手牌被塞进一个字符串(`"♠6 ♦6"`)也能拆开;
29
- - 对不上候选(mismatch)或解析失败(parse)时**立刻按张数最少的一手出牌**,不再白等兜底;
30
- - 只有 timeout / HTTP 错才交给 `localFallback`。
31
- - **出牌让槽(pauseMainDuringTurn)**:模型服务器只有一个并发槽位时,主会话那种几万 token 的生成会把槽位占住几十秒。
32
- 出牌期间先 `POST /api/run/pause`(事件照常落库、不唤醒),出完立刻 `resume`。
33
- - 主人手动暂停时不会被覆盖(出手前先查 `/api/status`);
34
- - `finally` 里无条件恢复 + 看门狗(`fastMoveTimeout + 15s`)+ 启动自检(`pause-ours.flag`),
35
- 避免进程在暂停期间被杀导致"重启后一直不回话"。
36
- - **总开关与时段**:`playWindowEnabled` 是总开关(关掉完全不连接、不打);`playWindowStart` 填 `0` 或留空 = 不限时段,
37
- 否则只在 `playWindowStart~playWindowEnd` 之间入座,支持跨零点(`22:00~02:00`)。
38
- 时段结束时**正在打的局会打完再断**,还没开局则 30 秒内断开;到点每 60 秒检查一次自动回来。
39
- - **赛后清上下文(resetContextAfterGame)**:结算后等 `resetContextDelayMs` 调 Coopanion 的
40
- `/api/session/reload-prefix`,丢掉这一局的对话上下文、用最新系统提示词重新开始(Memory 与人格保留)。
41
- 超时 90 秒、最多重试 3 次(她正在生成时 reload 要等当前批收尾)。
42
- - **启动容错**:没有可加入的桌 / 服务端超时不再把整个 World 标成"启动失败"并跳过一整天,
43
- 改为记 warn + 60 秒后重试。
44
- - 超时口径按服务端时限重排:`fastMoveTimeoutMs 32000` < `decisionDeadlineMs 38000` < 服务端 40000。
45
- - 工具仍只有一个 `ddz`(`state`/`act`/`chat`/`next`/`friend`/`square`)。
46
-
47
- ## 0.1.0
48
-
49
- - 首个版本:auth / WS / 事件投递 / `ddz` 工具 / 牌桌面板,按服务端网关 v2.0.1 契约实现。
1
+ # 更新日志
2
+
3
+ ## 0.2.3
4
+
5
+ - **说了不打就离桌**:之前投「不打」但服务端回 `pending`(其它座位未表态)时,到点会被默认「继续」拖进下一局;
6
+ 现在 `verdict != quit` 且自己的票是「不打」,2 秒后主动断桌换桌(日志 `next_leave` + `leave_table`),不再陪跑。
7
+ AI 座位本来就不在投票范围里,所以这不会影响真人局。
8
+
9
+ ## 0.2.2
10
+
11
+ - **自动续桌 `autoNext`**(配合服务端 S-1):打开后每局结算由服务端代投「继续」,不用每局表态;auth 请求体新增 `autoNext`(默认 false)。
12
+ 服务端仅在「开关开 + WS 在线 + 90 秒内有心跳」三者同时满足时代投,掉线自动失效;`/next {play:false}` 或 `auth {autoNext:false}` 立即停。
13
+ - **30 秒心跳**:每 30 秒 `GET /view`,刷新服务端判离线用的 `last_seen`,也顺便发现连接假死(第 1 次与每第 10 次记 `keepalive`,不刷屏)。
14
+ - **席位协商**:`next_vote` 日志接收服务端返回的 `pendingSeats`(还差哪些座位没表态)。
15
+ - **环境手册瘦身**:2,800 → 682 字符(省约 1,320 token/次请求)。
16
+ - **`ddz state` 瘦身**:候选只列前 `legalPreview`(默认 12)条,另给总数 `legalActionCount`。
17
+ - **修「表态」**:`play` 认中文/字符串/数字/缺省(12 例全过);`verdict=again` 后 800ms 主动刷新牌局;`verdict=quit` 清桌号并重新找桌。
18
+
19
+ ## 0.2.1
20
+
21
+ - **每日自动签到领欢乐豆**:连上服务端 3 秒后查一次签到状态,没签就签(服务端固定 100 豆/天);
22
+ 之后每 30 分钟复查,跨零点自动补签,已签不会重复请求(日志事件 `checkin` / `checkin_already` / `checkin_skipped`)。
23
+ 新增 `ddz {action:"checkin"}` 可手动查/签;`state` 里多了 `beans`(欢乐豆)与 `checkinDate`。
24
+ - **修复抢地主阶段判定**:服务端把抢地主阶段叫 `grab`(不是 `rob`),之前被当成叫分轮处理、
25
+ 一律提交 `pass_bid`;现在 `rob|grab` 都按抢地主处理(强牌抢、弱牌不抢)。
26
+ - **简介补充参与方式**:主人可在官网参与对局、观战(`/watch`)、点「插手」接管出牌;QQ 群 483590438。
27
+ - 面板错误提示不再常驻:一次拉取超时后,下一次成功就自动清掉红字(并 5 秒后自动重试)。
28
+
29
+ ## 0.2.0
30
+
31
+ - **时限对齐服务端 v2.0.2-772mu.1**(官方口径:出牌 30 秒、桌宠叫分 20 秒、看门狗 30~32 秒触发):
32
+ `fastMoveTimeoutMs` 24000、`decisionDeadlineMs` 28000,都压在 30 秒之前,本地兜底不会再比服务端晚。
33
+ (之前误按 40 秒设成 32000/38000,当地兜底永远轮不到。)
34
+ - **叫分本地判定(新增)**:叫分阶段(桌宠 20 秒)不再干等服务端托管——按手牌强度直接表态:
35
+ 双王/双 2/炸弹/多 A 计分,叫分轮 score≥9 叫地主、否则不叫;抢地主轮 score≥14 抢、否则不抢。
36
+ 不问模型、不推事件,决策写进 `actions.jsonl` 的 `fast_bid`(带 score / bidPhase)。
37
+ - **总开关断开时机**:关掉后,正在打的局**打完再断**(服务端规则:断线 30 秒不回来就解散房间,中途硬断会拆别人的局);
38
+ 不在牌局中(waiting / 已结算)时**5 秒内断开**(巡检从 30 秒改成 5 秒),配置热读、不用重启。
39
+
40
+ - **快决策(fastMove)**:轮到自己时不再把整张牌桌推进主会话,而是用 ~300 token 的小提示词直接问一次主模型
41
+ (手牌 / 上一手 / 两家剩几张 / 候选列表),拿到一手后**逐个对回服务端给的 legalActions** 再提交。
42
+ 成功即 `POST /api/match/<table>/act`,不推事件、不抢占对话轮。实测中位 2.0~2.9 秒一手。
43
+ - 模型爱用中文花色(`♠6 ♦6`)而不是 ASCII,解析层同时认 `♠6` / `6S` / `小王` / `SJ`;
44
+ - 一手牌被塞进一个字符串(`"♠6 ♦6"`)也能拆开;
45
+ - 对不上候选(mismatch)或解析失败(parse)时**立刻按张数最少的一手出牌**,不再白等兜底;
46
+ - 只有 timeout / HTTP 错才交给 `localFallback`。
47
+ - **出牌让槽(pauseMainDuringTurn)**:模型服务器只有一个并发槽位时,主会话那种几万 token 的生成会把槽位占住几十秒。
48
+ 出牌期间先 `POST /api/run/pause`(事件照常落库、不唤醒),出完立刻 `resume`。
49
+ - 主人手动暂停时不会被覆盖(出手前先查 `/api/status`);
50
+ - `finally` 里无条件恢复 + 看门狗(`fastMoveTimeout + 15s`)+ 启动自检(`pause-ours.flag`),
51
+ 避免进程在暂停期间被杀导致"重启后一直不回话"。
52
+ - **总开关与时段**:`playWindowEnabled` 是总开关(关掉完全不连接、不打);`playWindowStart` 填 `0` 或留空 = 不限时段,
53
+ 否则只在 `playWindowStart~playWindowEnd` 之间入座,支持跨零点(`22:00~02:00`)。
54
+ 时段结束时**正在打的局会打完再断**,还没开局则 30 秒内断开;到点每 60 秒检查一次自动回来。
55
+ - **赛后清上下文(resetContextAfterGame)**:结算后等 `resetContextDelayMs` 调 Coopanion 的
56
+ `/api/session/reload-prefix`,丢掉这一局的对话上下文、用最新系统提示词重新开始(Memory 与人格保留)。
57
+ 超时 90 秒、最多重试 3 次(她正在生成时 reload 要等当前批收尾)。
58
+ - **启动容错**:没有可加入的桌 / 服务端超时不再把整个 World 标成"启动失败"并跳过一整天,
59
+ 改为记 warn + 60 秒后重试。
60
+ - 超时口径按服务端时限重排:`fastMoveTimeoutMs 32000` < `decisionDeadlineMs 38000` < 服务端 40000。
61
+ - 工具仍只有一个 `ddz`(`state`/`act`/`chat`/`next`/`friend`/`square`)。
62
+
63
+ ## 0.1.0
64
+
65
+ - 首个版本:auth / WS / 事件投递 / `ddz` 工具 / 牌桌面板,按服务端网关 v2.0.1 契约实现。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-doudizhu",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "斗地主 World 扩展:桌面宠物自动入座、自己出牌,每天自动领欢乐豆;主人可在官网参与对局、观战(/watch)、或点「插手」接管出牌。QQ 群 483590438。",
6
6
  "author": "大肥鱼",
package/src/ENV_PROMPT.md CHANGED
@@ -1,62 +1,24 @@
1
1
  ## 斗地主
2
2
 
3
- 你在牌桌上,服务端是唯一裁判,你只负责选牌。**所有操作都走同一个工具 `ddz`,用 `action` 区分。**
3
+ 服务端是唯一裁判,你只负责**轮到你时出牌**和**打完说句话**。所有操作都走 `ddz`,用 `action` 区分。
4
4
 
5
- ### 每手怎么做
5
+ ### 每手
6
+ - 轮到你(`legalActions` 非空且不是主人接管)→ `ddz {action:"act", version:当前版本, cards:[...]}`,压不过就 `pass:true`
7
+ - 一手只提交一次;被拒(stale_version)先 `state` 再选,**别原样重发**
8
+ - 叫分已由本地规则处理,不用你操心
6
9
 
7
- 1. `ddz {action:"state"}` 看局面:自己的手牌、上一手、全部合法候选、`version`、`bidPhase`、`timer`
8
- 2. 轮到你(`timer` 非空、`legalActions` 非空)→ `ddz {action:"act", version:..., cards:[...]}` 或 `pass:true`
9
- 3. 收到回执才算完,**一手只提交一次**
10
+ ### 频道
11
+ 牌桌 `chat`(同桌三人)/私聊 `friend op:"chat"`(对方 uid:人类 `5H`、桌宠 `10001`)/广场 `square op:"send"`(全服,一分钟 3 条)。别串台。
10
12
 
11
- - `version` 必须取自最近一次 state / 事件;被拒就重新 state 再选,**绝不原样重发**
12
- - 别人出牌不会打扰你,只在你回合才推一条
13
-
14
- ### 叫分(每局开头,别跳过)
15
-
16
- `state` 里 `bidPhase` 非空就是叫分阶段,**只传 `bid`,不传 `cards`**:
17
-
18
- | `bidPhase` | 用 |
19
- |---|---|
20
- | `call` | `bid:"call"` 叫地主 / `bid:"pass_call"` 不叫 |
21
- | `grab` | `bid:"rob"` 抢(倍数×2 且换地主)/ `bid:"no_rob"` 不抢 |
22
-
23
- - 大小王多、2 多、有炸弹 → 叫,可以抢;一手小牌 → 不叫
24
- - **超时会被自动判「不叫 / 不抢」**,别拖着不动
25
-
26
- ### 三个频道,别串台
27
-
28
- | 频道 | 怎么用 | 谁看得见 |
29
- |---|---|---|
30
- | 牌桌 | `ddz {action:"chat", text}` | 同桌三人 |
31
- | 私聊 | `ddz {action:"friend", op:"chat", other_uid, text}` | 你俩 |
32
- | 广场 | `ddz {action:"square", op:"send", text}` | 全服 |
33
-
34
- 广场的话不是牌桌信息;私聊别复述到广场;牌桌的事只在牌桌说。广场一分钟最多 3 条。
35
-
36
- ### 认人
37
-
38
- - **人类**:数字 + 大写 H(`5H`、`12H`)
39
- - **桌宠**:纯数字(`10001`)
40
- - 加好友:`ddz {action:"friend", op:"request", other_uid:"5H"}`;同意:`op:"accept"`
41
- - 好友上限 10 个,满了先删(`op:"remove"`);人和宠物可以互相加
42
-
43
- ### 一局打完:接着打还是散场
44
-
45
- 结算后不要退房间,服务端会开一个表态窗口(`state` 里 `next` 字段,带 `leftMs` 剩余毫秒):
46
-
47
- - 还想打:`ddz {action:"next", play:true}` —— 大家都同意就**同一桌直接再发牌**,座位和令牌都不变
48
- - 不打算玩了:`ddz {action:"next", play:false}` —— 有人不打就散场
49
- - 想先问问别人:`ddz {action:"chat", text:"还来吗?"}` 再表态
50
- - 到点没人反对会默认接着打,所以**不想打一定要明说**
13
+ ### 局后
14
+ 结算后服务端开表态窗口:还想打 `ddz {action:"next", play:true}` 同桌重开;不打了 `play:false` 散场(到点没人反对会默认继续,不想打要明说)。
51
15
 
52
16
  ### 省上下文
53
-
54
- 广场、私聊都**按需读**(`op:"recent"` / `op:"messages"`),别反复刷。一局解说别超过三句,别把牌局念成流水账。
17
+ 广场/私聊按需读(`op:"recent"` / `op:"messages"`),别反复刷;一局解说别超过三句,别把牌念成流水账。
55
18
 
56
19
  当前权限:{{doudizhu.status}}
57
20
 
58
21
  ## 主人怎么一起玩
59
-
60
- - 观战/直播:https://www.772mu.com:8443/watch
61
- - 主人可在同平台登录后坐进同一张桌和你打;也能在面板点「主人接管」直接接管你这一座,说一句「交还」再还给你。
62
- - 有问题进 QQ 群 483590438。
22
+ - 观战:https://www.772mu.com:8443/watch
23
+ - 主人可登录同平台坐进同一张桌,或在面板点「主人接管」接管你这一座(说「交还」还给你)。
24
+ - 有问题进 QQ 群 483590438。
package/src/config.ts CHANGED
@@ -28,6 +28,8 @@ export interface DdzConfigSection {
28
28
  pauseMainDuringTurn: boolean;
29
29
  /** Coopanion 控制接口基址(暂停/继续、清空上下文都走它)。 */
30
30
  controlBaseUrl: string;
31
+ /** 自动续桌(服务端 S-1):每局结算自动投「继续」,关=每局自己投票。 */
32
+ autoNext: boolean;
31
33
  /** 总开关:关掉就不连接、不打牌;打开才入座。 */
32
34
  playWindowEnabled: boolean;
33
35
  /** 游戏开始时间(HH:MM);填 0 或留空 = 不限时间、整天都能打。 */
@@ -58,6 +60,7 @@ export const DDZ_DEFAULTS: DdzConfigSection = {
58
60
  resetContextUrl: 'http://127.0.0.1:17788/api/session/reload-prefix',
59
61
  pauseMainDuringTurn: false,
60
62
  controlBaseUrl: 'http://127.0.0.1:17788',
63
+ autoNext: false,
61
64
  playWindowEnabled: true,
62
65
  playWindowStart: '0',
63
66
  playWindowEnd: '',
@@ -138,7 +141,10 @@ export const DDZ_CONFIG_GROUP: ConfigGroup = {
138
141
  type: 'string', title: 'Coopanion 控制接口基址', 'x-hot': false,
139
142
  description: '默认 http://127.0.0.1:17788(网页端口按实际配置)。暂停/继续、清空上下文都用它。',
140
143
  },
141
- 'worlds.doudizhu.playWindowEnabled': {
144
+ 'worlds.doudizhu.autoNext': {
145
+ type: 'boolean', title: '自动续桌(每局自动投继续)', 'x-hot': true,
146
+ description: '打开后每局结算自动投「继续」,不用每局表态。服务端只在「开关开 + 连接在线 + 90 秒内有心跳」三者同时满足时才代投,掉线自动失效。关掉或调 next{play:false} 立即停止。默认关闭。',
147
+ }, 'worlds.doudizhu.playWindowEnabled': {
142
148
  type: 'boolean', title: '总开关(关=不连接、不打)', 'x-hot': true,
143
149
  description: '关掉:完全不连服务端、不入座、不出一张牌。打开:按下面的时间入座打牌。正在打的那一局会先打完再走。',
144
150
  },
package/src/world.ts CHANGED
@@ -89,6 +89,8 @@ export class DoudizhuWorld implements World {
89
89
  private windowTimer?: NodeJS.Timeout;
90
90
  /** 每日签到巡检定时器。 */
91
91
  private checkinTimer?: NodeJS.Timeout;
92
+ /** 30 秒心跳(刷新服务端 last_seen)。 */
93
+ private keepaliveTimer?: NodeJS.Timeout;
92
94
  /** 今天已经签到/尝试过的日期(YYYY-MM-DD),防重复请求。 */
93
95
  private checkinDate = '';
94
96
  /** 最近一次查到的欢乐豆余额。 */
@@ -137,6 +139,7 @@ export class DoudizhuWorld implements World {
137
139
  // 每日签到:连上服务端就查一次,跨天也会补
138
140
  setTimeout(() => void this.tryDailyCheckin(), 3000);
139
141
  this.startCheckinTimer();
142
+ this.startKeepalive();
140
143
  } catch (error) {
141
144
  // 没有可加入的桌 / 服务端超时都是常态:不要把整个世界标成"启动失败"然后一直跳过,
142
145
  // 留一个 60 秒的重试,桌子出现了自己就进去了。
@@ -193,6 +196,22 @@ export class DoudizhuWorld implements World {
193
196
  this.checkinTimer = setInterval(() => void this.tryDailyCheckin(), 30 * 60_000);
194
197
  }
195
198
 
199
+ /**
200
+ * 30 秒一次 GET /view:既给服务端刷新 last_seen(他们会按 90 秒判桌宠离线),
201
+ * 也顺便发现连接已经假死。第 1 次和之后每 10 次记一行,避免刷日志。
202
+ */
203
+ private startKeepalive(): void {
204
+ if (this.keepaliveTimer) return;
205
+ let beat = 0;
206
+ this.keepaliveTimer = setInterval(() => {
207
+ if (!this.ws || !this.token || !this.tableId) return;
208
+ beat += 1;
209
+ void this.refresh().then(() => {
210
+ if (beat === 1 || beat % 10 === 0) this.log({ event: 'keepalive', n: beat });
211
+ });
212
+ }, 30_000);
213
+ }
214
+
196
215
  private todayKey(): string {
197
216
  const now = new Date();
198
217
  const mm = String(now.getMonth() + 1).padStart(2, '0');
@@ -293,6 +312,10 @@ export class DoudizhuWorld implements World {
293
312
  clearTimeout(this.contextResetTimer);
294
313
  this.clearResumeWatchdog();
295
314
  this.clearWindowGuard();
315
+ if (this.keepaliveTimer) {
316
+ clearInterval(this.keepaliveTimer);
317
+ this.keepaliveTimer = undefined;
318
+ }
296
319
  if (this.windowTimer) {
297
320
  clearTimeout(this.windowTimer);
298
321
  this.windowTimer = undefined;
@@ -355,18 +378,64 @@ export class DoudizhuWorld implements World {
355
378
  * 全票「打」就直接在同一桌重开一局(座位、令牌、余额都不变),
356
379
  * 有人「不打」就散场——不用退出房间重走一遍流程。
357
380
  */
381
+ /** 把「接着打 / 不打」解析成布尔:中文、字符串、数字都认,缺省按"接着打"。 */
382
+ private parsePlayArg(raw: unknown): boolean {
383
+ if (raw === undefined || raw === null) return true;
384
+ if (typeof raw === 'boolean') return raw;
385
+ if (typeof raw === 'number') return raw !== 0;
386
+ const text = String(raw).trim().toLowerCase();
387
+ if (!text) return true;
388
+ if (/^(false|no|0|off|quit|stop|exit|leave)$/.test(text)) return false;
389
+ if (/不打|不玩|不接|散场|退出|算了/.test(text)) return false;
390
+ return true;
391
+ }
392
+
393
+ /** 离桌:断开当前桌,清桌号,重新找一张(说"不打"后调用,不等别人投票)。 */
394
+ private leaveTable(): void {
395
+ this.log({ event: 'leave_table', tableId: this.tableId });
396
+ clearTimeout(this.fallbackTimer);
397
+ this.fallbackTimer = undefined;
398
+ try {
399
+ this.ws?.close();
400
+ } catch {
401
+ /* 已关闭 */
402
+ }
403
+ this.ws = undefined;
404
+ this.tableId = '';
405
+ this.view = undefined;
406
+ setTimeout(() => void this.reconnect(), 1500);
407
+ }
408
+
358
409
  private async nextTool(args: Record<string, unknown>) {
359
410
  if (!this.tableId) return { failed: true as const, text: '还没入座,没有可结算的牌桌' };
360
411
  const raw = args.play ?? args.again ?? args.want;
361
- const play = !(raw === false || raw === 'no' || raw === 'false' || raw === 0);
412
+ const play = this.parsePlayArg(raw);
362
413
  try {
363
414
  const res = await this.platformPost(
364
415
  `/api/match/${encodeURIComponent(this.tableId)}/next`, { play });
365
416
  const code = Number(res.code);
366
417
  if (code === 1) return { failed: true as const, text: String(res.msg || '表态失败') };
367
418
  const verdict = String(res.verdict || '');
368
- if (verdict === 'again') return { text: '已表态继续,大家都在同一桌接着打,新一局已经发牌。' };
369
- if (verdict === 'quit') return { text: '已表态不打,本桌散场。' };
419
+ const pending = Array.isArray((res as Record<string, unknown>).pendingSeats) ? (res as Record<string, unknown>).pendingSeats : null;
420
+ this.log({ event: 'next_vote', play, verdict: verdict || 'pending', pendingSeats: pending });
421
+ if (verdict === 'again') {
422
+ // 同一张桌重开:立刻拉一次新牌局,别干等下一帧(否则就是"坐着不动")
423
+ setTimeout(() => void this.refresh(), 800);
424
+ return { text: `已表态${play ? '继续' : '不打'}(服务端判定:同桌重开),新一局已发牌。` };
425
+ }
426
+ if (verdict === 'quit') {
427
+ // 本桌散场:清掉桌号重新找一张,别死守一张已经没了的桌子
428
+ this.tableId = '';
429
+ setTimeout(() => void this.reconnect(), 1500);
430
+ return { text: `已表态${play ? '继续' : '不打'}(服务端判定:本桌散场)。我去找下一张桌。` };
431
+ }
432
+ if (!play) {
433
+ // 说了不打就走:AI 座位不投票,若服务端迟迟不判 quit,到点会默认"继续",把她拖进下一局。
434
+ // 所以表完态自己离桌,不等别人。
435
+ this.log({ event: 'next_leave', verdict: verdict || 'pending' });
436
+ setTimeout(() => this.leaveTable(), 2000);
437
+ return { text: '已表态不打。不等别人了,直接离桌换一张。' };
438
+ }
370
439
  return { text: `已表态${play ? '继续' : '不打'},等其他人表态。` };
371
440
  } catch (e) {
372
441
  return { failed: true as const, text: `表态失败:${(e as Error).message}` };
@@ -477,7 +546,8 @@ export class DoudizhuWorld implements World {
477
546
  seat: v.seat,
478
547
  hand: v.hand.map((c) => c),
479
548
  lastPlay: v.lastPlay ? { seat: v.lastPlay.seat, cards: v.lastPlay.cards } : null,
480
- legalActions: v.legalActions,
549
+ legalActions: v.legalActions.slice(0, Math.max(1, this.ctx.cfg.legalPreview || 12)),
550
+ legalActionCount: v.legalActions.length,
481
551
  timer: v.timer,
482
552
  bidPhase: (v as unknown as Record<string, unknown>).bidPhase ?? null,
483
553
  bidIsMe: (v as unknown as Record<string, unknown>).bidIsMe ?? false,
@@ -590,6 +660,7 @@ export class DoudizhuWorld implements World {
590
660
  // 昵称:配置优先,否则用部署显示名;服务端存进宠物档案,之后可 /api/pet/nickname 改
591
661
  displayName: nick,
592
662
  allowAi: this.ctx.cfg.allowAi,
663
+ autoNext: this.ctx.cfg.autoNext === true,
593
664
  }),
594
665
  signal: AbortSignal.timeout(15000),
595
666
  });