dsh-blackjack 0.1.4 → 0.2.0

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/README.md CHANGED
@@ -36,10 +36,11 @@
36
36
  | 命令 | 作用 |
37
37
  |---|---|
38
38
  | `/blackjack` | 查看当前状态:有牌局在打就显示牌局,没有就显示余额/免费手/兑换门槛 |
39
- | `/blackjack agree` | 首次使用必须先执行:阅读须知并同意后才能上桌 |
39
+ | `/blackjack agree` | 显示须知全文(第三方声明、不可提现、不可转让等)。**只显示,不代表同意** |
40
+ | `/blackjack agree confirm` | 阅读须知后确认同意,注册牌桌账户。**这一步之前,开局与兑换都会被拒绝** |
40
41
  | `/blackjack deal [注额]` | 开一局新牌。不带参数用当天的免费手;带一个正整数注额则从余额里加注 |
41
42
  | `/blackjack exchange [数量]` | 把达标余额兑换为可消费额度。**首次**(还没绑过 GitHub)会给出一个配对链接,需要去浏览器完成绑定;**绑定之后**带上数量就在命令里直接兑换,不再需要浏览器 |
42
- | `/blackjack reset confirm` | 清除**本机保存**的牌桌凭证(服务端记录不受影响,重新 `agree` 会注册一个新账户) |
43
+ | `/blackjack reset confirm` | 清除**本机保存**的牌桌凭证(服务端记录不受影响,重新 `agree confirm` 会注册一个新账户) |
43
44
  | `/bj-hit` | 要一张牌 |
44
45
  | `/bj-stand` | 停牌,进入结算 |
45
46
  | `/bj-double` | 加倍注额并再要一张牌 |
@@ -62,8 +63,9 @@
62
63
  的兑换接口,**不再生成配对码、不再需要打开浏览器**。不带数量时会先回显你的
63
64
  余额与门槛,并提示补上数量——兑换不可逆,插件不会替你猜一个数字。
64
65
 
65
- 余额没到门槛、或者数量超过余额,都会得到一句明确的中文提示("余额还没到兑换门
66
- 槛。" / "余额不足以完成这个动作。"),账本一分不动。
66
+ 余额没到门槛、或者数量超过余额,都会得到一句明确的提示(说清楚门槛是多少 CHIP、
67
+ 或者干脆说余额不够),账本一分不动。提示的语言跟 `locale` 配置项走——默认是
68
+ 英文,见下面「配置」一节。
67
69
 
68
70
  ---
69
71
 
@@ -74,9 +76,27 @@ dsh plugin --profile web add -w dsh-blackjack
74
76
  dsh --profile web
75
77
  ```
76
78
 
77
- 把 `web` 换成你实际要装的 profile 名。
79
+ 把 `web` 换成你实际要装的 profile 名。包已经发布到 npm,上面这条命令可以直接用。
78
80
 
79
- > **尚未发布到 npm 时**,上面这条命令还装不到东西。从源码装:
81
+ ### 先进入一个会话,再用 `/blackjack`
82
+
83
+ 在 dsh 的**启动页**(新开 dsh 时那个 “Into the Unknown” 界面)执行 `/blackjack`,
84
+ 命令会真的执行,但**画面上什么都不会出现**——牌桌要等你进入一个会话后才显示。
85
+
86
+ 这不是本插件的问题:宿主对**所有插件注册的命令**都这样(用一个只返回一行文字、
87
+ 不碰任何服务的最小插件验证过,表现完全一致),而宿主自己的内置命令(如 `/goal`)
88
+ 则会正常渲染并切进会话视图。
89
+
90
+ 所以**第一次使用请先让对话真正开始**——随便发一句话,或者从侧边栏打开一个已有
91
+ 对话——再执行 `/blackjack agree` 看须知、`/blackjack agree confirm` 确认。注意点
92
+ “新建会话”是没用的:那会把你送回启动页,正是坏掉的那个状态。
93
+
94
+ 只要对话已经开始,后面全程正常,牌桌、按钮、结算都没有任何问题。
95
+
96
+ 已向上游反馈:https://github.com/deepseek-ai/deepseek-harness/discussions/4066。
97
+ 复现用的最小插件(13 行、不碰 slot 也不碰服务)在本仓库 [`docs/repro/dsh-probe-repro/`](../../docs/repro/dsh-probe-repro/)。
98
+
99
+ > **想改代码、或者想装一个还没发到 npm 的开发版**,从源码装:
80
100
  >
81
101
  > ```sh
82
102
  > git clone https://github.com/yul761/dsh-blackjack
@@ -94,21 +114,6 @@ dsh --profile web
94
114
  出一个残缺的状态。这不是本插件独有的坑,其它社区 dsh 插件的安装文档也踩过同一
95
115
  处并确认了同样的结论。
96
116
 
97
- ### 先进入一个会话,再用 `/blackjack`
98
-
99
- 在 dsh 的**启动页**(新开 dsh 时那个 “Into the Unknown” 界面)执行 `/blackjack`,
100
- 命令会真的执行,但**画面上什么都不会出现**——牌桌要等你进入一个会话后才显示。
101
-
102
- 这不是本插件的问题:宿主对**所有插件注册的命令**都这样(用一个只返回一行文字、
103
- 不碰任何服务的最小插件验证过,表现完全一致),而宿主自己的内置命令(如 `/goal`)
104
- 则会正常渲染并切进会话视图。
105
-
106
- 所以**第一次使用请先让对话真正开始**——随便发一句话,或者从侧边栏打开一个已有
107
- 对话——再执行 `/blackjack agree`。注意点“新建会话”是没用的:那会把你送回启动页,
108
- 正是坏掉的那个状态。
109
-
110
- 只要对话已经开始,后面全程正常,牌桌、按钮、结算都没有任何问题。
111
-
112
117
  ### 版本要求:本插件锁定 dsh `0.1.0-rc.6`
113
118
 
114
119
  插件把 `@deepseek-ai/dsh-*` 的 peer 全部**精确锁**在 `0.1.0-rc.6`,不是写成
@@ -133,6 +138,7 @@ dsh --profile web
133
138
  |---|---|---|---|
134
139
  | `serverUrl` | string | `https://server-production-493c.up.railway.app` | 牌局服务端地址。**想自建奖池就改成你自己的部署地址**——服务端与本插件同仓开源,见仓库根 README 的部署一节 |
135
140
  | `enabled` | boolean | `true` | 设为 `false` 可保留安装但完全停用:不注册任何命令,也不挂任何路由或续命钩子 |
141
+ | `locale` | `'en'` \| `'zh'` | `'en'` | `/blackjack` 与 `/bj-*` 等**命令**回复的语言。**图形牌桌不看这一项**——它自动跟随 dsh 自己的 Settings → General → Language;用图形界面的玩家完全不用管这个配置。只有纯终端(没有 Web GUI)的 profile,才需要在这里手动设一次,因为 Node 侧读不到宿主的语言设置 |
136
142
 
137
143
  ```yaml
138
144
  # ~/.dsh/profiles/<profile>/cordis.patch.yml
@@ -154,7 +160,7 @@ dsh --profile web
154
160
  内容已经开始返回后中途换源,那样只会造成重复或截断;
155
161
  3. 调用是以**配额耗尽**这一类错误收尾的(不是网络错误、不是参数错误、不是别
156
162
  的任何原因);
157
- 4. 你在本插件里**已经完成过 `/blackjack agree`**(本机存有牌桌凭证;没有这份
163
+ 4. 你在本插件里**已经完成过 `/blackjack agree confirm`**(本机存有牌桌凭证;没有这份
158
164
  凭证,插件根本不会尝试)。
159
165
 
160
166
  这里只看凭证,**不预先检查你的已兑换额度够不够**。这是刻意的:查余额要在每
@@ -164,6 +170,16 @@ dsh --profile web
164
170
 
165
171
  四条同时成立时,插件才会把这一次失败的调用重发到奖池路由。
166
172
 
173
+ **奖池用哪个模型(重要):** 重发到奖池的请求,**由运营者在服务端指定模型**,
174
+ 不一定是你在界面上选中的那个。这是刻意的——奖池花的是运营者自己的上游余额,
175
+ 型号由出钱的人定;这样也免去了"你选了个服务端不认识的型号,续命白接管一场"
176
+ 这类失败。所以一次由奖池完成的调用,**界面上显示的型号可能与实际服务的型号
177
+ 不同**。当前运营者实例用的是 `deepseek-v4-flash`。
178
+
179
+ **同一时刻只能有一次奖池调用。** 服务端把单玩家并发上限设为 1:奖池请求会先
180
+ 冻结一笔估算额度,而刚过兑换门槛的余额只装得下一笔。第二个并发请求会得到明确
181
+ 的「请求太频繁」(429),而不是含糊的余额不足。
182
+
167
183
  **切换提示去哪了(请注意):** 成功切换时插件会输出一行「已切换至奖池余额继续
168
184
  本次请求。」,但在当前 dsh 版本里它**只写进 dsh 自己的日志,不会出现在对话界面
169
185
  里**——这个版本没有给插件提供任何"往对话里插一条非模型消息"的通道,唯一现成的
@@ -187,9 +203,10 @@ dsh --profile web
187
203
  - **不可转让**(含玩家之间转让),不可退换;
188
204
  - **不会进入任何模型厂商的账户**;
189
205
  - **唯一用途**是通过牌局服务端的续命路由消费——除此之外没有任何出口。
190
- - **有赛季有效期。** 已兑换额度有效期至本赛季末(含宽限期);连续一段时间无
191
- 游戏且无消费的账户余额会被回收进奖池。这些提醒会在插件里提前给出,兑换前也
192
- 会再提示一次。
206
+ - **不活跃会回收。** 连续 7 天无游戏且无消费的账户,余额会被回收进奖池。
207
+ - **已兑换额度目前不会过期。** 早期文案写作"有效期至本赛季末",但服务端的赛季
208
+ 结算并没有触碰已兑换额度,那句话与实现不符,因此改成如实描述。是否在赛季末
209
+ 清零尚未决定;真要做,会先公告。
193
210
  - **你自己的 API key 永远不会进入这套系统**:插件不读取、不托管、不代管你自
194
211
  己 provider 的任何密钥,续命路由用的是牌局服务端自己签发的凭证。
195
212
 
@@ -205,7 +222,7 @@ dsh --profile web
205
222
  ```
206
223
 
207
224
  (不带 `confirm` 时会先回显一遍这条操作的说明,避免误触。)这只清除**本机**保
208
- 存的凭证,服务端那边的账户记录不受影响;用 `/blackjack agree` 即可重新注册一
225
+ 存的凭证,服务端那边的账户记录不受影响;用 `/blackjack agree confirm` 即可重新注册一
209
226
  个新账户。
210
227
 
211
228
  要完全移除插件本身:
@@ -11,3 +11,13 @@
11
11
 
12
12
  # 设为 false 可保留安装但完全停用(不注册命令、不挂路由)。
13
13
  # enabled: true
14
+
15
+ # 命令回复的语言,默认 en。图形牌桌不看这一项——它自动跟随 dsh 自己的
16
+ # Settings → General → Language;只有没有图形界面的纯终端 profile 才
17
+ # 需要在这里手动设一次(Node 侧读不到宿主的语言设置)。
18
+ # Language for command replies, default en. The graphical table
19
+ # ignores this — it follows dsh's own Settings → General → Language
20
+ # automatically. Only a terminal-only profile (no web GUI) needs to
21
+ # set this by hand, because the Node side cannot read the host's
22
+ # language setting.
23
+ # locale: zh
package/dist/api.d.ts CHANGED
@@ -46,11 +46,22 @@ export interface MeView {
46
46
  */
47
47
  maxRaiseBetChips?: number;
48
48
  }
49
- /** Every failure this client surfaces: HTTP status plus the server's error code. */
49
+ /**
50
+ * Every failure this client surfaces: HTTP status plus the server's error code.
51
+ *
52
+ * `details` carries whatever structured fields the server put alongside
53
+ * `code` (e.g. `thresholdChips`, `capChips`, `maxChips`, `roundId`) — Task 7's
54
+ * server-side envelope (`packages/server/src/http.ts`'s `playerError`) nests
55
+ * those as siblings of `code`/`message` inside `error`, and `commands.ts`'s
56
+ * `mapError` reads them back out to put a real number in a sentence instead
57
+ * of a code alone. Defaults to `{}` so every existing two/three-arg call site
58
+ * (including a pile of test fixtures) keeps compiling unchanged.
59
+ */
50
60
  export declare class ApiError extends Error {
51
61
  readonly status: number;
52
62
  readonly code?: string | undefined;
53
- constructor(status: number, code?: string | undefined, message?: string);
63
+ readonly details: Record<string, unknown>;
64
+ constructor(status: number, code?: string | undefined, message?: string, details?: Record<string, unknown>);
54
65
  }
55
66
  export interface Api {
56
67
  register(): Promise<{
@@ -71,11 +82,24 @@ export interface Api {
71
82
  * one an already-bound player takes on every repeat exchange. First-time
72
83
  * binding still has to go through `startPairing` and the browser, because
73
84
  * only a browser can complete GitHub's authorization-code flow.
85
+ *
86
+ * `quota` is structured facts, not prose (Task 6 deleted the server's
87
+ * `exchangedQuotaNote()` — see `packages/plugin/src/i18n/messages.ts` for
88
+ * why: the server doesn't know the caller's language, so wording moved to
89
+ * `Messages.exchangedQuotaNote`). **Optional for the same cross-version
90
+ * reason as `maxRaiseBetChips` above**: a published plugin can talk to a
91
+ * server that predates this field and still only sends the old `note`
92
+ * string. `commands.ts` skips the quota line rather than crashing when it's
93
+ * missing — degrade like the graphical table does for `maxRaiseBetChips`,
94
+ * don't throw.
74
95
  */
75
96
  exchange(token: string, chips: number): Promise<{
76
97
  chips: number;
77
98
  exchangedChips: number;
78
- note: string;
99
+ quota?: {
100
+ expiresAtSeasonEnd: boolean;
101
+ inactiveRecycleDays: number;
102
+ };
79
103
  }>;
80
104
  startPairing(token: string): Promise<{
81
105
  code: string;
package/dist/api.js CHANGED
@@ -1,14 +1,54 @@
1
- /** Every failure this client surfaces: HTTP status plus the server's error code. */
1
+ /**
2
+ * Every failure this client surfaces: HTTP status plus the server's error code.
3
+ *
4
+ * `details` carries whatever structured fields the server put alongside
5
+ * `code` (e.g. `thresholdChips`, `capChips`, `maxChips`, `roundId`) — Task 7's
6
+ * server-side envelope (`packages/server/src/http.ts`'s `playerError`) nests
7
+ * those as siblings of `code`/`message` inside `error`, and `commands.ts`'s
8
+ * `mapError` reads them back out to put a real number in a sentence instead
9
+ * of a code alone. Defaults to `{}` so every existing two/three-arg call site
10
+ * (including a pile of test fixtures) keeps compiling unchanged.
11
+ */
2
12
  export class ApiError extends Error {
3
13
  status;
4
14
  code;
5
- constructor(status, code, message) {
15
+ details;
16
+ constructor(status, code, message, details = {}) {
6
17
  super(message ?? `api error ${status}${code ? `: ${code}` : ''}`);
7
18
  this.status = status;
8
19
  this.code = code;
20
+ this.details = details;
9
21
  this.name = 'ApiError';
10
22
  }
11
23
  }
24
+ /**
25
+ * Turn a non-2xx response's parsed `error` field into an `ApiError`.
26
+ *
27
+ * This is the defect Task 7 exists to fix, made explicit: the old code did
28
+ * `new ApiError(status, typeof body?.error === 'string' ? body.error : undefined)`
29
+ * — handing the SERVER'S ENGLISH MESSAGE to the constructor's `code`
30
+ * parameter. Everything downstream that thought it was matching on a stable
31
+ * code (`commands.ts`'s `mapError`) was really doing string-matching on
32
+ * English prose, which is exactly what this whole task exists to stop doing.
33
+ *
34
+ * Task 7's server envelope (`packages/server/src/http.ts`'s `playerError`)
35
+ * nests a real `{ code, message, ...details }` object under `error`. When
36
+ * that shape is present, `code`/`details` come from it and `message` is kept
37
+ * only as diagnostic `Error.message` text (never shown to a player — see
38
+ * `Messages.genericFailure`'s doc comment). When it is NOT present — a bare
39
+ * string, e.g. a pre-Task-7 server, or `openaiError`'s `{message,type}` shape
40
+ * from the proxy path — `code` stays `undefined` rather than being set to
41
+ * that prose. An undefined code is exactly what `mapError`'s unknown-code
42
+ * fallback is built to degrade gracefully from; silently reusing the old
43
+ * message-as-code behavior would resurrect the bug this task fixes.
44
+ */
45
+ function toApiError(status, err) {
46
+ if (err && typeof err === 'object' && !Array.isArray(err)) {
47
+ const { code, message, ...details } = err;
48
+ return new ApiError(status, typeof code === 'string' ? code : undefined, typeof message === 'string' ? message : undefined, details);
49
+ }
50
+ return new ApiError(status, undefined, typeof err === 'string' ? err : undefined);
51
+ }
12
52
  export function createApi(opts) {
13
53
  const base = opts.serverUrl.replace(/\/+$/, '');
14
54
  const f = opts.fetchImpl ?? fetch;
@@ -31,7 +71,7 @@ export function createApi(opts) {
31
71
  const text = await res.text();
32
72
  const body = text ? safeParse(text) : {};
33
73
  if (!res.ok)
34
- throw new ApiError(res.status, typeof body?.error === 'string' ? body.error : undefined);
74
+ throw toApiError(res.status, body?.error);
35
75
  return body;
36
76
  }
37
77
  function safeParse(text) {
@@ -1,4 +1,5 @@
1
1
  import type { MeView, RoundView } from '../api.js';
2
+ import type { Messages } from '../i18n/index.js';
2
3
  /**
3
4
  * Pure presentational graphical blackjack table, rendered inside the
4
5
  * `conversation.chat.commandview` slot for the `/blackjack` command row.
@@ -12,6 +13,12 @@ import type { MeView, RoundView } from '../api.js';
12
13
  * All player-facing copy here must stay within the same red lines as the
13
14
  * command-plane text renderer (`render.ts`): no gambling vocabulary, no
14
15
  * model-vendor branding tied to the game, CHIP (never RMB) as the only unit.
16
+ *
17
+ * Every player-facing string comes from the `m: Messages` prop threaded down
18
+ * from `client/index.tsx`, which resolves it from the browser's own locale
19
+ * (see the `useLocaleId` hook there) — this component itself is locale-blind
20
+ * on purpose, so its tests can pin either language by passing the matching
21
+ * `m` in.
15
22
  * @module dsh-blackjack/client/Table
16
23
  */
17
24
  /** Shape of the `GET /blackjack/api/state` response (see `routes.ts`). */
@@ -22,6 +29,7 @@ export interface BlackjackApiState {
22
29
  }
23
30
  export interface BlackjackTableProps {
24
31
  state: BlackjackApiState;
32
+ m: Messages;
25
33
  onAction: (action: string) => void;
26
34
  onDeal: (betChips?: number) => void;
27
35
  /** 玩家收起已结算的牌局,回到空闲态。 */
@@ -50,5 +58,5 @@ export declare function actionCostChips(action: string, betChips: number): numbe
50
58
  export declare function betError(chips: number, opts: {
51
59
  balanceChips: number;
52
60
  maxChips: number;
53
- }): string | null;
54
- export declare function BlackjackTable({ state, onAction, onDeal, onDismiss, error }: BlackjackTableProps): JSX.Element;
61
+ }, m: Messages): string | null;
62
+ export declare function BlackjackTable({ state, m, onAction, onDeal, onDismiss, error }: BlackjackTableProps): JSX.Element;
@@ -4,9 +4,6 @@ import { stakeLine, outcomeLine } from '../copy.js';
4
4
  const SUITS = { S: '♠', H: '♥', D: '♦', C: '♣' };
5
5
  const RED_SUITS = new Set(['H', 'D']);
6
6
  const n = (v) => v.toLocaleString('en-US');
7
- const ACTION_LABELS = {
8
- hit: '要牌', stand: '停牌', double: '双倍', split: '分牌', insure: '买保险', decline: '不买保险',
9
- };
10
7
  const CSS = `
11
8
  .bj-table { --bj-felt: #0f3d2e; --bj-line: rgba(255,255,255,.18); font-size: 14px; }
12
9
  .bj-hand { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin: 4px 0; }
@@ -55,23 +52,28 @@ export function Linkified({ text }) {
55
52
  ? _jsx("a", { href: part, target: "_blank", rel: "noreferrer noopener", children: part }, i)
56
53
  : part) }));
57
54
  }
58
- function CardFace({ card }) {
55
+ function CardFace({ card, m }) {
59
56
  if ('hidden' in card) {
60
- return _jsx("div", { className: "bj-card bj-card-back", "aria-label": "\u6697\u724C" });
57
+ return _jsx("div", { className: "bj-card bj-card-back", "aria-label": m.hiddenCardLabel() });
61
58
  }
62
59
  const suit = SUITS[card.suit] ?? card.suit;
63
60
  const cls = RED_SUITS.has(card.suit) ? 'bj-card-red' : 'bj-card-black';
64
61
  return _jsx("div", { className: `bj-card ${cls}`, children: `${card.rank}${suit}` });
65
62
  }
66
- function HandRow({ hand, label }) {
67
- const soft = hand.soft ? '' : '';
68
- return (_jsxs("div", { children: [_jsxs("div", { className: "bj-meta", children: [label, "\uFF08", soft, hand.total, "\uFF09", hand.doubled ? ' [双倍]' : ''] }), _jsx("div", { className: "bj-hand", children: hand.cards.map((card, i) => (_jsx(CardFace, { card: card }, i))) })] }));
63
+ function HandRow({ hand, label, m }) {
64
+ return (_jsxs("div", { children: [_jsxs("div", { className: "bj-meta", children: [label, m.totalInParens(hand.total, hand.soft), hand.doubled ? m.doubled() : ''] }), _jsx("div", { className: "bj-hand", children: hand.cards.map((card, i) => (_jsx(CardFace, { card: card, m: m }, i))) })] }));
69
65
  }
70
- function DealerRow({ dealer }) {
71
- return (_jsxs("div", { children: [_jsxs("div", { className: "bj-meta", children: ["\u5E84\u5BB6", dealer.total !== undefined ? `(${dealer.total})` : ''] }), _jsx("div", { className: "bj-hand", children: dealer.cards.map((card, i) => (_jsx(CardFace, { card: card }, i))) })] }));
66
+ function DealerRow({ dealer, m }) {
67
+ return (_jsxs("div", { children: [_jsxs("div", { className: "bj-meta", children: [m.tableDealerLabel(), dealer.total !== undefined ? m.totalInParens(dealer.total, false) : ''] }), _jsx("div", { className: "bj-hand", children: dealer.cards.map((card, i) => (_jsx(CardFace, { card: card, m: m }, i))) })] }));
72
68
  }
73
- function ConsentPrompt() {
74
- return (_jsx("div", { className: "bj-table", children: _jsxs("p", { children: ["\u8BF7\u5148\u5728\u547D\u4EE4\u884C\u8F93\u5165 ", _jsx("code", { children: "/blackjack agree" }), "\uFF0C\u9605\u8BFB\u5E76\u540C\u610F\u89C4\u5219\u540E\u624D\u80FD\u5F00\u59CB\u3002"] }) }));
69
+ /**
70
+ * 同意前的界面。**必须把须知全文渲染出来**——原先这里只写"阅读并同意规则"
71
+ * 不显示规则,GUI 用户从头到尾没见过条款就被引导去同意,那份须知(第三方声明、
72
+ * 不可提现、不可转让、赛季有效期)也就失去了意义。同意动作仍然只能在命令行
73
+ * 完成:牌桌上不放"同意"按钮,避免一次误点就代表接受条款。
74
+ */
75
+ function ConsentPrompt({ m }) {
76
+ return (_jsx("div", { className: "bj-table", children: _jsx("div", { style: { whiteSpace: 'pre-wrap' }, children: m.consentText() }) }));
75
77
  }
76
78
  /**
77
79
  * 余额条。除了数字,还必须回答"赢来的 CHIP 有什么用"——只用图形界面的人此前
@@ -79,14 +81,14 @@ function ConsentPrompt() {
79
81
  * CHIP 能干什么,而那正是这个产品的全部意义。没到门槛时显示还差多少,本身
80
82
  * 就是回访的理由。
81
83
  */
82
- function MeHeader({ me }) {
84
+ function MeHeader({ me, m }) {
83
85
  const short = me.exchangeThresholdChips - me.chips;
84
- return (_jsxs("div", { className: "bj-meta", children: ["\u4F59\u989D\uFF1A", n(me.chips), " CHIP\u3000\u4ECA\u65E5\u5269\u4F59\u514D\u8D39\u624B\uFF1A", me.freeHandsRemaining, me.points > 0 ? ` 积分:${n(me.points)}` : '', me.exchangedChips > 0 ? ` 已兑换额度:${n(me.exchangedChips)} CHIP` : '', _jsx("div", { className: "bj-note", children: short <= 0
85
- ? `已达兑换门槛(${n(me.exchangeThresholdChips)} CHIP)· 用 /blackjack exchange 把 CHIP 换成模型额度`
86
- : `还差 ${n(short)} CHIP 到兑换门槛(${n(me.exchangeThresholdChips)})· 过线后可用 /blackjack exchange 换成模型额度` }), me.exchangedChips > 0 ? (_jsxs("div", { className: "bj-note", children: ["\u5DF2\u5151\u6362\u7684 ", n(me.exchangedChips), " CHIP \u4F1A\u5728\u4F60\u81EA\u5DF1\u7684\u6A21\u578B\u989D\u5EA6\u8017\u5C3D\u65F6\u81EA\u52A8\u63A5\u7BA1\u90A3\u4E00\u6B21\u8BF7\u6C42\uFF0C\u5E73\u65F6\u4E0D\u4F1A\u52A8\u7528\u3002"] })) : null, me.poolEmpty ? _jsx("div", { children: "\uFF08\u672C\u671F\u5956\u6C60\u5DF2\u53D1\u5B8C\uFF0C\u73B0\u5728\u8D62\u724C\u5C06\u53D1\u653E\u79EF\u5206\uFF09" }) : null, me.sponsorText ? _jsx("div", { children: me.sponsorText }) : null] }));
86
+ return (_jsxs("div", { className: "bj-meta", children: [m.tableBalanceHeader(me.chips, me.freeHandsRemaining), me.points > 0 ? m.tablePointsSuffix(me.points) : '', me.exchangedChips > 0 ? m.tableExchangedSuffix(me.exchangedChips) : '', _jsx("div", { className: "bj-note", children: short <= 0
87
+ ? m.tableExchangeThresholdReached(me.exchangeThresholdChips)
88
+ : m.tableExchangeThresholdShort(short, me.exchangeThresholdChips) }), me.exchangedChips > 0 ? (_jsx("div", { className: "bj-note", children: m.tableExchangedQuotaNote(me.exchangedChips) })) : null, me.poolEmpty ? _jsx("div", { children: m.tablePoolEmptyNotice() }) : null, me.sponsorText ? _jsx("div", { children: me.sponsorText }) : null] }));
87
89
  }
88
- function RoundFooter({ round }) {
89
- return (_jsxs("div", { className: "bj-meta", children: [round.phase === 'settled' ? outcomeLine(round) : stakeLine(round), round.pointsGranted ? _jsxs("div", { children: ["\u672C\u671F\u5956\u6C60\u5DF2\u53D1\u5B8C\uFF0C\u6539\u4E3A\u53D1\u653E ", n(round.pointsGranted), " \u79EF\u5206\u3002"] }) : null] }));
90
+ function RoundFooter({ round, m }) {
91
+ return (_jsxs("div", { className: "bj-meta", children: [round.phase === 'settled' ? outcomeLine(round, m) : stakeLine(round, m), round.pointsGranted ? _jsx("div", { children: m.pointsGrantedNotice(round.pointsGranted) }) : null] }));
90
92
  }
91
93
  /**
92
94
  * 动作要额外掏多少 CHIP。镜像服务端 `game.ts` 的 INCREMENT:双倍/分牌各再出
@@ -99,68 +101,69 @@ export function actionCostChips(action, betChips) {
99
101
  return Math.ceil(betChips / 2);
100
102
  return 0;
101
103
  }
102
- function ActionBar({ round, me, onAction }) {
104
+ function ActionBar({ round, me, onAction, m }) {
103
105
  if (round.actions.length === 0)
104
106
  return null;
105
107
  const chips = me?.chips ?? 0;
108
+ const labels = m.tableActionLabels;
106
109
  return (_jsx("div", { className: "bj-actions", children: round.actions.map((action) => {
107
110
  // 服务端只按牌面给可选动作,不看余额;余额不够时点下去必然 402,
108
111
  // 只换来一句笼统的失败提示。这里替玩家先拦住并说明原因。
109
112
  const cost = actionCostChips(action, round.betChips);
110
113
  const short = cost > chips;
111
- return (_jsxs("button", { type: "button", disabled: short, title: short ? `还要再出 ${n(cost)} CHIP,余额不足` : undefined, onClick: () => onAction(action), children: [ACTION_LABELS[action] ?? action, cost > 0 ? `(${n(cost)})` : ''] }, action));
114
+ return (_jsxs("button", { type: "button", disabled: short, title: short ? m.actionShortfallHint(cost) : undefined, onClick: () => onAction(action), children: [labels[action] ?? action, cost > 0 ? m.tableCostSuffix(cost) : ''] }, action));
112
115
  }) }));
113
116
  }
114
117
  /**
115
118
  * 注额校验。抽成导出的纯函数,好让边界(0、小数、超余额、超服务端上限)
116
119
  * 能被直接断言,而不是只能靠驱动输入框间接覆盖。
117
120
  */
118
- export function betError(chips, opts) {
121
+ export function betError(chips, opts, m) {
119
122
  if (!Number.isInteger(chips) || chips < 1)
120
- return '注额要填正整数。';
123
+ return m.invalidBetAmount();
121
124
  if (chips > opts.maxChips)
122
- return `单局最多 ${n(opts.maxChips)} CHIP。`;
125
+ return m.betAmountExceedsCap(opts.maxChips);
123
126
  if (chips > opts.balanceChips)
124
- return '余额不足以下这个注。';
127
+ return m.betAmountExceedsBalance();
125
128
  return null;
126
129
  }
127
- function DealButtons({ me, onDeal, freeLabel }) {
130
+ function DealButtons({ me, onDeal, freeLabel, m }) {
128
131
  const freeLeft = me?.freeHandsRemaining ?? 0;
129
132
  const presets = me ? [me.baseBetChips, me.baseBetChips * 2] : [];
130
- return (_jsxs("div", { className: "bj-actions", children: [_jsxs("button", { type: "button", disabled: freeLeft <= 0, onClick: () => onDeal(), children: [freeLabel, me ? `·剩 ${freeLeft} 手` : ''] }), presets.map((amount) => (_jsxs("button", { type: "button", disabled: (me?.chips ?? 0) < amount, onClick: () => onDeal(amount), children: [n(amount), " CHIP"] }, amount)))] }));
133
+ return (_jsxs("div", { className: "bj-actions", children: [_jsxs("button", { type: "button", disabled: freeLeft <= 0, onClick: () => onDeal(), children: [freeLabel, me ? m.dealButtonFreeHandsSuffix(freeLeft) : ''] }), presets.map((amount) => (_jsxs("button", { type: "button", disabled: (me?.chips ?? 0) < amount, onClick: () => onDeal(amount), children: [n(amount), " CHIP"] }, amount)))] }));
131
134
  }
132
135
  /**
133
136
  * 自定义注额。**只在服务端给出了 `maxRaiseBetChips` 时出现**——插件可能连到
134
137
  * 比这个字段更早的服务端,那时没有可信上界,宁可退化成只有预设按钮,也不要
135
138
  * 让玩家填一个必定被服务端 400 掉的数字。
136
139
  */
137
- function CustomBet({ me, onDeal }) {
140
+ function CustomBet({ me, onDeal, m }) {
138
141
  const max = me.maxRaiseBetChips;
139
142
  const [open, setOpen] = useState(false);
140
143
  const [text, setText] = useState('');
141
144
  if (max === undefined)
142
145
  return null;
143
146
  const chips = Number(text);
144
- const err = text.trim() === '' ? '请输入注额。' : betError(chips, { balanceChips: me.chips, maxChips: max });
145
- return (_jsxs("div", { children: [_jsx("div", { className: "bj-actions", children: _jsx("button", { type: "button", onClick: () => setOpen(!open), children: "\u81EA\u5B9A\u4E49\u6CE8\u989D" }) }), open ? (_jsxs("div", { className: "bj-custom", children: [_jsx("input", { type: "number", min: 1, max: max, value: text, onChange: (e) => setText(e.target.value), "aria-label": "\u81EA\u5B9A\u4E49\u6CE8\u989D" }), _jsx("button", { type: "button", disabled: err !== null, onClick: () => onDeal(chips), children: "\u5F00\u59CB" }), _jsx("span", { className: "bj-hint", children: err ?? `1 ~ ${n(max)} CHIP` })] })) : null] }));
147
+ const err = text.trim() === '' ? m.betAmountMissing() : betError(chips, { balanceChips: me.chips, maxChips: max }, m);
148
+ return (_jsxs("div", { children: [_jsx("div", { className: "bj-actions", children: _jsx("button", { type: "button", onClick: () => setOpen(!open), children: m.customBetToggle() }) }), open ? (_jsxs("div", { className: "bj-custom", children: [_jsx("input", { type: "number", min: 1, max: max, value: text, onChange: (e) => setText(e.target.value), "aria-label": m.customBetToggle() }), _jsx("button", { type: "button", disabled: err !== null, onClick: () => onDeal(chips), children: m.customBetSubmit() }), _jsx("span", { className: "bj-hint", children: err ?? m.customBetRange(max) })] })) : null] }));
146
149
  }
147
- function IdleTable({ me, onDeal }) {
150
+ function IdleTable({ me, onDeal, m }) {
148
151
  const freeLeft = me?.freeHandsRemaining ?? 0;
149
152
  const base = me?.baseBetChips ?? 0;
150
153
  const presets = me ? [base, base * 2] : [];
151
154
  const affordable = me ? presets.some((a) => me.chips >= a) : false;
152
- return (_jsxs("div", { children: [_jsx("div", { className: "bj-meta", children: "\u5F53\u524D\u6CA1\u6709\u8FDB\u884C\u4E2D\u7684\u724C\u5C40\u3002" }), _jsxs("div", { className: "bj-group", children: [_jsxs("div", { className: "bj-group-title", children: ["\u514D\u8D39\u624B \u00B7 ", freeLeft > 0 ? `今日剩 ${freeLeft} 手` : '今日已用完,明天再来'] }), me ? (_jsxs("div", { className: "bj-note", children: ["\u4E0D\u7528\u51FA CHIP\u3002\u8D62\u4E86\u62FF ", n(base), " CHIP\uFF1B\u5934\u4E24\u5F20\u724C\u5C31\u51D1\u6210 21 \u70B9\uFF08A \u914D 10/J/Q/K\uFF09\u62FF", ' ', n(Math.floor(base * 1.5)), "\u3002\u8F93\u4E86\u6216\u5E73\u5C40\u90FD\u4E0D\u635F\u5931\u4EFB\u4F55\u4E1C\u897F\u3002"] })) : null, _jsx("div", { className: "bj-actions", children: _jsx("button", { type: "button", disabled: freeLeft <= 0, onClick: () => onDeal(), children: "\u5F00\u59CB\u514D\u8D39\u7684\u4E00\u5C40" }) })] }), me ? (_jsxs("div", { className: "bj-group", children: [_jsxs("div", { className: "bj-group-title", children: ["\u7528\u4F59\u989D\u52A0\u6CE8", affordable ? '' : ' · 余额不足'] }), _jsx("div", { className: "bj-note", children: "\u52A0\u6CE8\u7684 CHIP \u4ECE\u4F60\u7684\u4F59\u989D\u91CC\u51FA\uFF0C\u8D62\u4E86\u7FFB\u500D\uFF0C\u8F93\u4E86\u6536\u4E0D\u56DE\u3002" }), _jsx("div", { className: "bj-actions", children: presets.map((amount) => (_jsxs("button", { type: "button", disabled: me.chips < amount, onClick: () => onDeal(amount), children: [n(amount), " CHIP"] }, amount))) }), _jsx(CustomBet, { me: me, onDeal: onDeal })] })) : null] }));
155
+ return (_jsxs("div", { children: [_jsx("div", { className: "bj-meta", children: m.noActiveRoundNotice() }), _jsxs("div", { className: "bj-group", children: [_jsxs("div", { className: "bj-group-title", children: [m.freeHandsGroupTitle(), freeLeft > 0 ? m.freeHandsRemainingToday(freeLeft) : m.freeHandsExhaustedToday()] }), me ? (_jsx("div", { className: "bj-note", children: m.freeHandExplainer(base, Math.floor(base * 1.5)) })) : null, _jsx("div", { className: "bj-actions", children: _jsx("button", { type: "button", disabled: freeLeft <= 0, onClick: () => onDeal(), children: m.startFreeHandButton() }) })] }), me ? (_jsxs("div", { className: "bj-group", children: [_jsxs("div", { className: "bj-group-title", children: [m.raiseGroupTitle(), affordable ? '' : m.insufficientBalanceSuffix()] }), _jsx("div", { className: "bj-note", children: m.raiseExplainer() }), _jsx("div", { className: "bj-actions", children: presets.map((amount) => (_jsxs("button", { type: "button", disabled: me.chips < amount, onClick: () => onDeal(amount), children: [n(amount), " CHIP"] }, amount))) }), _jsx(CustomBet, { me: me, onDeal: onDeal, m: m })] })) : null] }));
153
156
  }
154
157
  /**
155
158
  * 结算后的出口。牌局结算不再让画面直接跳回空闲态——完整牌面与结果留在原地,
156
159
  * 由玩家决定再来一局还是收起(0.1.2 之前这里什么都没有,爆牌瞬间就被冲掉了)。
157
160
  */
158
- function SettledBar({ me, onDeal, onDismiss }) {
159
- return (_jsxs("div", { children: [_jsx(DealButtons, { me: me, onDeal: onDeal, freeLabel: "\u518D\u6765\u4E00\u5C40\uFF08\u514D\u8D39" }), _jsx("div", { className: "bj-actions", children: _jsx("button", { type: "button", onClick: onDismiss, children: "\u6536\u8D77" }) })] }));
161
+ function SettledBar({ me, onDeal, onDismiss, m }) {
162
+ return (_jsxs("div", { children: [_jsx(DealButtons, { me: me, onDeal: onDeal, freeLabel: m.rematchFreeLabel(), m: m }), _jsx("div", { className: "bj-actions", children: _jsx("button", { type: "button", onClick: onDismiss, children: m.dismissButton() }) })] }));
160
163
  }
161
- export function BlackjackTable({ state, onAction, onDeal, onDismiss, error }) {
164
+ export function BlackjackTable({ state, m, onAction, onDeal, onDismiss, error }) {
162
165
  if (!state.consented)
163
- return _jsx(ConsentPrompt, {});
166
+ return _jsx(ConsentPrompt, { m: m });
164
167
  const { me, round } = state;
165
- return (_jsxs("div", { className: "bj-table", children: [_jsx("style", { children: CSS }), error ? _jsx("div", { className: "bj-error", children: error }) : null, me ? _jsx(MeHeader, { me: me }) : null, round ? (_jsxs("div", { children: [round.hands.map((hand, i) => (_jsx(HandRow, { hand: hand, label: round.hands.length > 1 ? `你的第 ${i + 1} 手` : '你的牌' }, i))), _jsx(DealerRow, { dealer: round.dealer }), _jsx(RoundFooter, { round: round }), _jsx(ActionBar, { round: round, me: me, onAction: onAction }), round.phase === 'settled' ? _jsx(SettledBar, { me: me, onDeal: onDeal, onDismiss: onDismiss }) : null] })) : (_jsx(IdleTable, { me: me, onDeal: onDeal }))] }));
168
+ return (_jsxs("div", { className: "bj-table", children: [_jsx("style", { children: CSS }), error ? _jsx("div", { className: "bj-error", children: error }) : null, me ? _jsx(MeHeader, { me: me, m: m }) : null, round ? (_jsxs("div", { children: [round.hands.map((hand, i) => (_jsx(HandRow, { hand: hand, m: m, label: round.hands.length > 1 ? m.tableSplitHandLabel(i + 1) : m.tableHandLabel() }, i))), _jsx(DealerRow, { dealer: round.dealer, m: m }), _jsx(RoundFooter, { round: round, m: m }), _jsx(ActionBar, { round: round, me: me, onAction: onAction, m: m }), round.phase === 'settled' ? _jsx(SettledBar, { me: me, onDeal: onDeal, onDismiss: onDismiss, m: m }) : null] })) : (_jsx(IdleTable, { me: me, onDeal: onDeal, m: m }))] }));
166
169
  }
@@ -1,4 +1,5 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
+ import { type LocaleId } from '../i18n/index.js';
2
3
  /**
3
4
  * Client module entry: registers the graphical table into the KEYED
4
5
  * `conversation.chat.commandview` slot under `key: 'blackjack'`, so every
@@ -9,4 +10,23 @@ import type { Context } from '@deepseek-ai/cordis';
9
10
  * @module dsh-blackjack/client
10
11
  */
11
12
  export declare const inject: string[];
13
+ /** The augmented `ctx.locale` shape (see the type-only import above); absent when the locale plugin isn't in the profile. */
14
+ export type LocaleRuntime = Context['locale'];
15
+ /**
16
+ * `getSnapshot`:单独导出成一个不依赖 hook 的纯函数,好让"每次调用都重新读
17
+ * `runtime.getSnapshot().active`,而不是只在挂载时读一次就缓存住"这条能被
18
+ * 直接断言——`useSyncExternalStore` 本身的"收到 subscribe 通知后重新渲染"
19
+ * 这条契约是 React 自己保证、经过充分测试的机制,这个包真正可能回归的地方
20
+ * 是这条:`getSnapshot` 有没有老老实实转发到 `runtime`。
21
+ */
22
+ export declare function resolveLocaleId(runtime: LocaleRuntime | undefined): LocaleId;
23
+ /**
24
+ * `subscribe`:同样单独导出成纯函数,好让"传给 `useSyncExternalStore` 的
25
+ * `subscribe` 真的把 onChange 转发给了 `runtime.subscribe`,而不是悄悄变成
26
+ * 一个什么都不做的空函数"这条能被直接断言——这正是"自动跟随退化成挂载时
27
+ * 生效一次"这个回归的另一半:`getSnapshot` 就算读得对,`subscribe` 不转发的
28
+ * 话 React 永远不会知道要重新调用它。
29
+ */
30
+ export declare function subscribeToLocale(runtime: LocaleRuntime | undefined, onChange: () => void): () => void;
31
+ export declare function useLocaleId(runtime: LocaleRuntime | undefined): LocaleId;
12
32
  export declare function apply(ctx: Context): void;