dsh-blackjack 0.1.2 → 0.1.4

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
@@ -94,6 +94,21 @@ dsh --profile web
94
94
  出一个残缺的状态。这不是本插件独有的坑,其它社区 dsh 插件的安装文档也踩过同一
95
95
  处并确认了同样的结论。
96
96
 
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
+
97
112
  ### 版本要求:本插件锁定 dsh `0.1.0-rc.6`
98
113
 
99
114
  插件把 `@deepseek-ai/dsh-*` 的 peer 全部**精确锁**在 `0.1.0-rc.6`,不是写成
package/dist/api.d.ts CHANGED
@@ -40,6 +40,11 @@ export interface MeView {
40
40
  exchangeThresholdChips: number;
41
41
  baseBetChips: number;
42
42
  githubLogin: string | null;
43
+ /**
44
+ * 加注上限。**可选**:插件发到 npm 后可能连到任何一版服务端,比这个字段更早的
45
+ * 服务端不会返回它。缺失时图形牌桌只隐藏自定义注额输入,预设按钮照常可用。
46
+ */
47
+ maxRaiseBetChips?: number;
43
48
  }
44
49
  /** Every failure this client surfaces: HTTP status plus the server's error code. */
45
50
  export declare class ApiError extends Error {
@@ -24,7 +24,31 @@ export interface BlackjackTableProps {
24
24
  state: BlackjackApiState;
25
25
  onAction: (action: string) => void;
26
26
  onDeal: (betChips?: number) => void;
27
+ /** 玩家收起已结算的牌局,回到空闲态。 */
28
+ onDismiss: () => void;
27
29
  /** Brief inline notice for a failed action/deal call (container-owned; cleared on the next successful one). */
28
30
  error?: string;
29
31
  }
30
- export declare function BlackjackTable({ state, onAction, onDeal, error }: BlackjackTableProps): JSX.Element;
32
+ /**
33
+ * 把纯文本里的裸链接变成可点的。命令回复是纯文本(终端 profile 也要能读),
34
+ * 但在图形界面里让人手动复制粘贴一条 60 字符的 URL 是不合理的——运营者的
35
+ * 原话是「应该直接打开一个浏览器」。插件进程里 spawn `open` 依赖运行环境
36
+ * 且很唐突,可点的链接是这个场景下最合适的实现。
37
+ */
38
+ export declare function Linkified({ text }: {
39
+ text: string;
40
+ }): JSX.Element;
41
+ /**
42
+ * 动作要额外掏多少 CHIP。镜像服务端 `game.ts` 的 INCREMENT:双倍/分牌各再出
43
+ * 一份注额,保险出一半。抽成导出的纯函数,好让"余额不足时必须置灰"能被断言。
44
+ */
45
+ export declare function actionCostChips(action: string, betChips: number): number;
46
+ /**
47
+ * 注额校验。抽成导出的纯函数,好让边界(0、小数、超余额、超服务端上限)
48
+ * 能被直接断言,而不是只能靠驱动输入框间接覆盖。
49
+ */
50
+ export declare function betError(chips: number, opts: {
51
+ balanceChips: number;
52
+ maxChips: number;
53
+ }): string | null;
54
+ export declare function BlackjackTable({ state, onAction, onDeal, onDismiss, error }: BlackjackTableProps): JSX.Element;
@@ -1,4 +1,6 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { stakeLine, outcomeLine } from '../copy.js';
2
4
  const SUITS = { S: '♠', H: '♥', D: '♦', C: '♣' };
3
5
  const RED_SUITS = new Set(['H', 'D']);
4
6
  const n = (v) => v.toLocaleString('en-US');
@@ -24,13 +26,35 @@ const CSS = `
24
26
  font: inherit; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--bj-line);
25
27
  background: rgba(255,255,255,.08); color: inherit; cursor: pointer;
26
28
  }
27
- .bj-actions button:hover { background: rgba(255,255,255,.16); }
29
+ .bj-actions button:hover:not(:disabled) { background: rgba(255,255,255,.16); }
30
+ .bj-actions button:disabled { opacity: .4; cursor: not-allowed; }
31
+ .bj-custom { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
32
+ .bj-custom input {
33
+ font: inherit; width: 9em; padding: 5px 8px; border-radius: 6px;
34
+ border: 1px solid var(--bj-line); background: rgba(255,255,255,.06); color: inherit;
35
+ }
36
+ .bj-hint { opacity: .7; font-size: 12px; }
37
+ .bj-group { margin-top: 12px; }
38
+ .bj-group-title { font-weight: 600; margin-bottom: 2px; }
39
+ .bj-note { opacity: .75; font-size: 12px; margin-bottom: 6px; }
28
40
  .bj-meta { opacity: .85; margin-bottom: 8px; }
29
41
  .bj-error {
30
42
  color: #c0392b; border: 1px solid rgba(192,57,43,.4); border-radius: 6px;
31
43
  padding: 4px 8px; margin-bottom: 8px;
32
44
  }
33
45
  `;
46
+ /**
47
+ * 把纯文本里的裸链接变成可点的。命令回复是纯文本(终端 profile 也要能读),
48
+ * 但在图形界面里让人手动复制粘贴一条 60 字符的 URL 是不合理的——运营者的
49
+ * 原话是「应该直接打开一个浏览器」。插件进程里 spawn `open` 依赖运行环境
50
+ * 且很唐突,可点的链接是这个场景下最合适的实现。
51
+ */
52
+ export function Linkified({ text }) {
53
+ const parts = text.split(/(https?:\/\/[^\s,。))]+)/g);
54
+ return (_jsx(_Fragment, { children: parts.map((part, i) => /^https?:\/\//.test(part)
55
+ ? _jsx("a", { href: part, target: "_blank", rel: "noreferrer noopener", children: part }, i)
56
+ : part) }));
57
+ }
34
58
  function CardFace({ card }) {
35
59
  if ('hidden' in card) {
36
60
  return _jsx("div", { className: "bj-card bj-card-back", "aria-label": "\u6697\u724C" });
@@ -49,26 +73,94 @@ function DealerRow({ dealer }) {
49
73
  function ConsentPrompt() {
50
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"] }) }));
51
75
  }
76
+ /**
77
+ * 余额条。除了数字,还必须回答"赢来的 CHIP 有什么用"——只用图形界面的人此前
78
+ * 完全看不到兑换这回事(门槛只在文字命令的 renderMe 里出现过),于是不知道
79
+ * CHIP 能干什么,而那正是这个产品的全部意义。没到门槛时显示还差多少,本身
80
+ * 就是回访的理由。
81
+ */
52
82
  function MeHeader({ me }) {
53
- 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.poolEmpty ? '(本期奖池已发完,现在赢牌将发放积分)' : '', me.sponsorText ? _jsx("div", { children: me.sponsorText }) : null] }));
83
+ 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] }));
54
87
  }
55
88
  function RoundFooter({ round }) {
56
- if (round.phase !== 'settled')
57
- return null;
58
- const payout = round.payoutChips ?? 0;
59
- return (_jsxs("div", { className: "bj-meta", children: [payout > 0 ? `本手赢得 ${n(payout)} CHIP。` : '本手没有收获,明天再来。', round.pointsGranted ? _jsxs("div", { children: ["\u672C\u671F\u5956\u6C60\u5DF2\u53D1\u5B8C\uFF0C\u6539\u4E3A\u53D1\u653E ", n(round.pointsGranted), " \u79EF\u5206\u3002"] }) : null] }));
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] }));
60
90
  }
61
- function ActionBar({ round, onAction }) {
91
+ /**
92
+ * 动作要额外掏多少 CHIP。镜像服务端 `game.ts` 的 INCREMENT:双倍/分牌各再出
93
+ * 一份注额,保险出一半。抽成导出的纯函数,好让"余额不足时必须置灰"能被断言。
94
+ */
95
+ export function actionCostChips(action, betChips) {
96
+ if (action === 'double' || action === 'split')
97
+ return betChips;
98
+ if (action === 'insure')
99
+ return Math.ceil(betChips / 2);
100
+ return 0;
101
+ }
102
+ function ActionBar({ round, me, onAction }) {
62
103
  if (round.actions.length === 0)
63
104
  return null;
64
- return (_jsx("div", { className: "bj-actions", children: round.actions.map((action) => (_jsx("button", { type: "button", onClick: () => onAction(action), children: ACTION_LABELS[action] ?? action }, action))) }));
105
+ const chips = me?.chips ?? 0;
106
+ return (_jsx("div", { className: "bj-actions", children: round.actions.map((action) => {
107
+ // 服务端只按牌面给可选动作,不看余额;余额不够时点下去必然 402,
108
+ // 只换来一句笼统的失败提示。这里替玩家先拦住并说明原因。
109
+ const cost = actionCostChips(action, round.betChips);
110
+ 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));
112
+ }) }));
113
+ }
114
+ /**
115
+ * 注额校验。抽成导出的纯函数,好让边界(0、小数、超余额、超服务端上限)
116
+ * 能被直接断言,而不是只能靠驱动输入框间接覆盖。
117
+ */
118
+ export function betError(chips, opts) {
119
+ if (!Number.isInteger(chips) || chips < 1)
120
+ return '注额要填正整数。';
121
+ if (chips > opts.maxChips)
122
+ return `单局最多 ${n(opts.maxChips)} CHIP。`;
123
+ if (chips > opts.balanceChips)
124
+ return '余额不足以下这个注。';
125
+ return null;
126
+ }
127
+ function DealButtons({ me, onDeal, freeLabel }) {
128
+ const freeLeft = me?.freeHandsRemaining ?? 0;
129
+ 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)))] }));
131
+ }
132
+ /**
133
+ * 自定义注额。**只在服务端给出了 `maxRaiseBetChips` 时出现**——插件可能连到
134
+ * 比这个字段更早的服务端,那时没有可信上界,宁可退化成只有预设按钮,也不要
135
+ * 让玩家填一个必定被服务端 400 掉的数字。
136
+ */
137
+ function CustomBet({ me, onDeal }) {
138
+ const max = me.maxRaiseBetChips;
139
+ const [open, setOpen] = useState(false);
140
+ const [text, setText] = useState('');
141
+ if (max === undefined)
142
+ return null;
143
+ 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] }));
146
+ }
147
+ function IdleTable({ me, onDeal }) {
148
+ const freeLeft = me?.freeHandsRemaining ?? 0;
149
+ const base = me?.baseBetChips ?? 0;
150
+ const presets = me ? [base, base * 2] : [];
151
+ 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] }));
65
153
  }
66
- function NoRound({ me, onDeal }) {
67
- 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-actions", children: [_jsx("button", { type: "button", onClick: () => onDeal(), children: "\u5F00\u59CB\u4E00\u5C40\uFF08\u9650\u514D\uFF09" }), me ? (_jsxs("button", { type: "button", onClick: () => onDeal(me.baseBetChips), children: ["\u5F00\u59CB\u4E00\u5C40\uFF08", n(me.baseBetChips), " CHIP\uFF09"] })) : null] })] }));
154
+ /**
155
+ * 结算后的出口。牌局结算不再让画面直接跳回空闲态——完整牌面与结果留在原地,
156
+ * 由玩家决定再来一局还是收起(0.1.2 之前这里什么都没有,爆牌瞬间就被冲掉了)。
157
+ */
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" }) })] }));
68
160
  }
69
- export function BlackjackTable({ state, onAction, onDeal, error }) {
161
+ export function BlackjackTable({ state, onAction, onDeal, onDismiss, error }) {
70
162
  if (!state.consented)
71
163
  return _jsx(ConsentPrompt, {});
72
164
  const { me, round } = state;
73
- 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, onAction: onAction })] })) : (_jsx(NoRound, { me: me, onDeal: onDeal }))] }));
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 }))] }));
74
166
  }
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useState } from 'react';
3
- import { BlackjackTable } from './Table.js';
3
+ import { BlackjackTable, Linkified } from './Table.js';
4
+ import { reduce, rowPlan } from './state.js';
4
5
  /**
5
6
  * Client module entry: registers the graphical table into the KEYED
6
7
  * `conversation.chat.commandview` slot under `key: 'blackjack'`, so every
@@ -23,41 +24,64 @@ async function getJson(path, init) {
23
24
  }
24
25
  /** Brief, vocabulary-safe notice shown when an action/deal call fails; never echoes the raw server error text. */
25
26
  const ACTION_FAILED_NOTICE = '上一次操作没有成功,已为你刷新到最新状态。';
26
- /** Container: owns data fetching. Refetches state after every action — no polling timer. */
27
- function BlackjackTableContainer() {
27
+ /**
28
+ * Container: owns data fetching. 牌局以发牌/动作的**返回值**为准,只有玩家点
29
+ * "收起"才清空;余额刷新单独走,绝不碰牌局。0.1.2 之前这里把动作返回值整个
30
+ * 丢掉、再拉一次 /api/state,而那个端点只报进行中的牌局——于是玩家一爆牌,
31
+ * 画面就跳回"当前没有进行中的牌局",从头到尾看不见自己怎么输的。
32
+ */
33
+ function BlackjackTableContainer({ node }) {
34
+ // 这一行渲染什么由子命令决定。keyed 槽按命令名分发,注册 `blackjack` 会顶掉
35
+ // 宿主负责渲染文字的 GenericCommandCard —— 不自己把文字渲染回来,
36
+ // /blackjack exchange 的配对码和 /blackjack agree 的须知就会凭空消失。
37
+ const plan = rowPlan(node);
28
38
  const [state, setState] = useState(null);
29
39
  const [actionError, setActionError] = useState(null);
30
- const refresh = useCallback(() => {
40
+ /** 整份同步:挂载时,以及动作失败后状态存疑时。 */
41
+ const sync = useCallback(() => {
31
42
  getJson('/blackjack/api/state')
32
- .then(setState)
43
+ .then((payload) => setState((s) => reduce(s ?? { consented: false }, { type: 'synced', payload })))
33
44
  .catch(() => setState({ consented: false }));
34
45
  }, []);
46
+ /** 只取 me:动作之后余额与免费手会变,但牌局必须留着。 */
47
+ const refreshMe = useCallback(() => {
48
+ getJson('/blackjack/api/state')
49
+ .then((payload) => setState((s) => (s ? reduce(s, { type: 'meRefreshed', me: payload.me }) : s)))
50
+ .catch(() => { });
51
+ }, []);
35
52
  useEffect(() => {
36
- refresh();
37
- }, [refresh]);
38
- const runAction = useCallback((path, body) => {
53
+ if (plan.table)
54
+ sync();
55
+ }, [sync, plan.table]);
56
+ const post = useCallback((path, body) => {
39
57
  getJson(path, {
40
58
  method: 'POST',
41
59
  headers: { 'content-type': 'application/json' },
42
60
  body: JSON.stringify(body),
43
61
  })
44
- .then(() => {
62
+ .then((round) => {
45
63
  setActionError(null);
46
- refresh();
64
+ setState((s) => (s ? reduce(s, { type: 'round', round }) : s));
65
+ refreshMe();
47
66
  })
48
67
  .catch(() => {
49
- // A failed action (including the 404 "no active round" case) still
50
- // resyncs state so the UI never shows a stale action bar, but the
51
- // notice stays up until the next successful action clears it.
68
+ // 失败(含 404 "no active round")后牌局状态存疑,整份重同步,
69
+ // 免得动作栏停在一个已经不存在的牌局上。
52
70
  setActionError(ACTION_FAILED_NOTICE);
53
- refresh();
71
+ sync();
54
72
  });
55
- }, [refresh]);
56
- const onDeal = useCallback((betChips) => runAction('/blackjack/api/deal', betChips === undefined ? {} : { betChips }), [runAction]);
57
- const onAction = useCallback((action) => runAction('/blackjack/api/action', { action }), [runAction]);
73
+ }, [refreshMe, sync]);
74
+ const onDeal = useCallback((betChips) => post('/blackjack/api/deal', betChips === undefined ? {} : { betChips }), [post]);
75
+ const onAction = useCallback((action) => post('/blackjack/api/action', { action }), [post]);
76
+ const onDismiss = useCallback(() => setState((s) => (s ? reduce(s, { type: 'dismissed' }) : s)), []);
77
+ // 样式内联:文字型的行不渲染牌桌,Table 里那段 <style> 也就不会挂载,
78
+ // 靠 class 的话须知全文会被压成一行。
79
+ const notice = plan.text === undefined ? null : (_jsx("div", { style: { whiteSpace: 'pre-wrap', marginBottom: '8px', ...(plan.isError ? { color: '#c0392b' } : {}) }, children: _jsx(Linkified, { text: plan.text }) }));
80
+ if (!plan.table)
81
+ return notice;
58
82
  if (state === null)
59
- return null;
60
- return _jsx(BlackjackTable, { state: state, onAction: onAction, onDeal: onDeal, error: actionError ?? undefined });
83
+ return notice;
84
+ return (_jsxs("div", { children: [notice, _jsx(BlackjackTable, { state: state, onAction: onAction, onDeal: onDeal, onDismiss: onDismiss, error: actionError ?? undefined })] }));
61
85
  }
62
86
  export function apply(ctx) {
63
87
  ctx.slots.inject('conversation.chat.commandview', () => ctx.slots.register({
@@ -0,0 +1,67 @@
1
+ import type { MeView, RoundView } from '../api.js';
2
+ /**
3
+ * 图形牌桌的状态迁移。抽成纯函数是为了让"结算后的牌局必须留在屏幕上"这条
4
+ * 能被直接断言——它原先埋在 React 容器里,没有 DOM 环境就测不到,于是
5
+ * 0.1.2 带着这个缺陷发了出去。
6
+ *
7
+ * 关键不变式:**只有 `dismissed` 能清掉 `round`。** 余额刷新绝不碰它。
8
+ * @module dsh-blackjack/client/state
9
+ */
10
+ export interface TableState {
11
+ consented: boolean;
12
+ me?: MeView;
13
+ round?: RoundView;
14
+ }
15
+ export type TableEvent =
16
+ /** 挂载时的一次性同步:整份状态以服务端为准(含"当前没有进行中的牌局")。 */
17
+ {
18
+ type: 'synced';
19
+ payload: {
20
+ consented: boolean;
21
+ me?: MeView;
22
+ round?: RoundView;
23
+ };
24
+ }
25
+ /** 发牌/动作的返回值。牌局以它为准——包括结算态,那正是要留在屏幕上的东西。 */
26
+ | {
27
+ type: 'round';
28
+ round: RoundView;
29
+ }
30
+ /** 动作之后余额与免费手会变,只更新 me。 */
31
+ | {
32
+ type: 'meRefreshed';
33
+ me?: MeView;
34
+ }
35
+ /** 玩家点了"收起",回到空闲态。 */
36
+ | {
37
+ type: 'dismissed';
38
+ };
39
+ export declare function reduce(state: TableState, event: TableEvent): TableState;
40
+ /** 宿主传给命令行组件的那份命令生命周期节点(只用到我们关心的字段)。 */
41
+ export interface CommandNodeLike {
42
+ args: string | null;
43
+ outcome: {
44
+ kind: 'success' | 'error';
45
+ text?: string;
46
+ } | null;
47
+ }
48
+ /** 这一行该渲染什么。 */
49
+ export interface RowPlan {
50
+ /** 要显示的命令回复文字;缺省表示这一行不靠文字表达。 */
51
+ text?: string;
52
+ /** 该文字是否是失败提示。 */
53
+ isError: boolean;
54
+ /** 是否渲染图形牌桌。 */
55
+ table: boolean;
56
+ }
57
+ /**
58
+ * 决定 `/blackjack …` 的某一行怎么渲染。
59
+ *
60
+ * 存在的理由:`conversation.chat.commandview` 是**按命令名分发**的 keyed 槽,
61
+ * 注册 `blackjack` 会顶掉宿主负责渲染文字的 GenericCommandCard。0.1.3 的容器
62
+ * 完全无视这个节点、永远渲染牌桌,于是 `/blackjack exchange` 的配对码和
63
+ * `/blackjack agree` 的须知全文被凭空吞掉——运营者因此以为"命令没反应"。
64
+ *
65
+ * 不变式:**永远不吞文字**。未知子命令默认走文字,所以以后新增子命令不会再犯。
66
+ */
67
+ export declare function rowPlan(node: CommandNodeLike): RowPlan;
@@ -0,0 +1,34 @@
1
+ export function reduce(state, event) {
2
+ switch (event.type) {
3
+ case 'synced':
4
+ return { consented: event.payload.consented, me: event.payload.me, round: event.payload.round };
5
+ case 'round':
6
+ return { ...state, round: event.round };
7
+ case 'meRefreshed':
8
+ return { ...state, me: event.me };
9
+ case 'dismissed':
10
+ return { ...state, round: undefined };
11
+ }
12
+ }
13
+ /** 牌桌是这些子命令的正确视图;其余一律以文字为准。 */
14
+ const TABLE_SUBCOMMANDS = new Set(['', 'deal']);
15
+ /**
16
+ * 决定 `/blackjack …` 的某一行怎么渲染。
17
+ *
18
+ * 存在的理由:`conversation.chat.commandview` 是**按命令名分发**的 keyed 槽,
19
+ * 注册 `blackjack` 会顶掉宿主负责渲染文字的 GenericCommandCard。0.1.3 的容器
20
+ * 完全无视这个节点、永远渲染牌桌,于是 `/blackjack exchange` 的配对码和
21
+ * `/blackjack agree` 的须知全文被凭空吞掉——运营者因此以为"命令没反应"。
22
+ *
23
+ * 不变式:**永远不吞文字**。未知子命令默认走文字,所以以后新增子命令不会再犯。
24
+ */
25
+ export function rowPlan(node) {
26
+ const sub = (node.args ?? '').trim().split(/\s+/)[0] ?? '';
27
+ const isTable = TABLE_SUBCOMMANDS.has(sub);
28
+ const outcome = node.outcome;
29
+ const isError = outcome?.kind === 'error';
30
+ // 牌桌型子命令平时不重复显示文字(牌桌已经把状态画出来了),但失败提示必须露出来。
31
+ if (isTable)
32
+ return { table: true, isError, ...(isError && outcome?.text ? { text: outcome.text } : {}) };
33
+ return { table: false, isError, ...(outcome?.text !== undefined ? { text: outcome.text } : {}) };
34
+ }
package/dist/client.js CHANGED
@@ -3,7 +3,7 @@ window.__ModuleLoader__.load({
3
3
  factory: (require) => {
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
- "use strict";var b=Object.defineProperty;var v=Object.getOwnPropertyDescriptor;var j=Object.getOwnPropertyNames;var x=Object.prototype.hasOwnProperty;var h=(e,n)=>{for(var a in n)b(e,a,{get:n[a],enumerable:!0})},S=(e,n,a,i)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of j(n))!x.call(e,o)&&o!==a&&b(e,o,{get:()=>n[o],enumerable:!(i=v(n,o))||i.enumerable});return e};var w=e=>S(b({},"__esModule",{value:!0}),e);var $={};h($,{apply:()=>P,inject:()=>I});module.exports=w($);var s=require("react");var t=require("react/jsx-runtime"),y={S:"\u2660",H:"\u2665",D:"\u2666",C:"\u2663"},C=new Set(["H","D"]),d=e=>e.toLocaleString("en-US"),N={hit:"\u8981\u724C",stand:"\u505C\u724C",double:"\u53CC\u500D",split:"\u5206\u724C",insure:"\u4E70\u4FDD\u9669",decline:"\u4E0D\u4E70\u4FDD\u9669"},E=`
6
+ "use strict";var C=Object.defineProperty;var P=Object.getOwnPropertyDescriptor;var V=Object.getOwnPropertyNames;var $=Object.prototype.hasOwnProperty;var L=(e,n)=>{for(var i in n)C(e,i,{get:n[i],enumerable:!0})},B=(e,n,i,o)=>{if(n&&typeof n=="object"||typeof n=="function")for(let a of V(n))!$.call(e,a)&&a!==i&&C(e,a,{get:()=>n[a],enumerable:!(o=P(n,a))||o.enumerable});return e};var D=e=>B(C({},"__esModule",{value:!0}),e);var ae={};L(ae,{apply:()=>oe,inject:()=>te});module.exports=D(ae);var l=require("react");var j=require("react");var m=e=>e.toLocaleString("en-US");function y(e){return e.mode==="free"?`\u8FD9\u662F\u514D\u8D39\u7684\u4E00\u5C40\uFF1A\u8D62\u4E86\u62FF ${m(e.betChips)} CHIP\uFF0C\u8F93\u4E86\u4E0D\u635F\u5931\u4EFB\u4F55\u4E1C\u897F\u3002`:`\u672C\u5C40\u5DF2\u52A0\u6CE8 ${m(e.betChips)} CHIP\uFF0C\u4ECE\u4F60\u7684\u4F59\u989D\u91CC\u51FA\u3002`}function w(e){let n=e.payoutChips??0,i=e.betChips;return e.hands.length>0&&e.hands.every(o=>o.outcome==="push")?e.mode==="free"?"\u5E73\u5C40\u3002\u8FD9\u624B\u4E0D\u635F\u5931\u4E5F\u4E0D\u83B7\u5F97\u3002":`\u5E73\u5C40\u3002\u52A0\u6CE8\u7684 ${m(i)} CHIP \u539F\u6837\u9000\u56DE\u3002`:n>0?e.mode==="free"?`\u8D62\u4E86\uFF0C\u62FF\u5230 ${m(n)} CHIP\u3002`:`\u8D62\u4E86\uFF0C\u62FF\u56DE ${m(n)} CHIP\uFF08\u52A0\u6CE8 ${m(i)}\uFF09\u3002`:e.mode==="free"?"\u8FD9\u624B\u8F93\u4E86\u3002\u514D\u8D39\u624B\u4E0D\u635F\u5931\u4EFB\u4F55\u4E1C\u897F\u3002":`\u8FD9\u624B\u8F93\u4E86\uFF0C\u52A0\u6CE8\u7684 ${m(i)} CHIP \u6536\u4E0D\u56DE\u3002`}var t=require("react/jsx-runtime"),M={S:"\u2660",H:"\u2665",D:"\u2666",C:"\u2663"},J=new Set(["H","D"]),d=e=>e.toLocaleString("en-US"),A={hit:"\u8981\u724C",stand:"\u505C\u724C",double:"\u53CC\u500D",split:"\u5206\u724C",insure:"\u4E70\u4FDD\u9669",decline:"\u4E0D\u4E70\u4FDD\u9669"},X=`
7
7
  .bj-table { --bj-felt: #0f3d2e; --bj-line: rgba(255,255,255,.18); font-size: 14px; }
8
8
  .bj-hand { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin: 4px 0; }
9
9
  .bj-card {
@@ -22,13 +22,23 @@ window.__ModuleLoader__.load({
22
22
  font: inherit; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--bj-line);
23
23
  background: rgba(255,255,255,.08); color: inherit; cursor: pointer;
24
24
  }
25
- .bj-actions button:hover { background: rgba(255,255,255,.16); }
25
+ .bj-actions button:hover:not(:disabled) { background: rgba(255,255,255,.16); }
26
+ .bj-actions button:disabled { opacity: .4; cursor: not-allowed; }
27
+ .bj-custom { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
28
+ .bj-custom input {
29
+ font: inherit; width: 9em; padding: 5px 8px; border-radius: 6px;
30
+ border: 1px solid var(--bj-line); background: rgba(255,255,255,.06); color: inherit;
31
+ }
32
+ .bj-hint { opacity: .7; font-size: 12px; }
33
+ .bj-group { margin-top: 12px; }
34
+ .bj-group-title { font-weight: 600; margin-bottom: 2px; }
35
+ .bj-note { opacity: .75; font-size: 12px; margin-bottom: 6px; }
26
36
  .bj-meta { opacity: .85; margin-bottom: 8px; }
27
37
  .bj-error {
28
38
  color: #c0392b; border: 1px solid rgba(192,57,43,.4); border-radius: 6px;
29
39
  padding: 4px 8px; margin-bottom: 8px;
30
40
  }
31
- `;function u({card:e}){if("hidden"in e)return(0,t.jsx)("div",{className:"bj-card bj-card-back","aria-label":"\u6697\u724C"});let n=y[e.suit]??e.suit,a=C.has(e.suit)?"bj-card-red":"bj-card-black";return(0,t.jsx)("div",{className:`bj-card ${a}`,children:`${e.rank}${n}`})}function A({hand:e,label:n}){let a=e.soft?"\u8F6F":"";return(0,t.jsxs)("div",{children:[(0,t.jsxs)("div",{className:"bj-meta",children:[n,"\uFF08",a,e.total,"\uFF09",e.doubled?" [\u53CC\u500D]":""]}),(0,t.jsx)("div",{className:"bj-hand",children:e.cards.map((i,o)=>(0,t.jsx)(u,{card:i},o))})]})}function T({dealer:e}){return(0,t.jsxs)("div",{children:[(0,t.jsxs)("div",{className:"bj-meta",children:["\u5E84\u5BB6",e.total!==void 0?`\uFF08${e.total}\uFF09`:""]}),(0,t.jsx)("div",{className:"bj-hand",children:e.cards.map((n,a)=>(0,t.jsx)(u,{card:n},a))})]})}function B(){return(0,t.jsx)("div",{className:"bj-table",children:(0,t.jsxs)("p",{children:["\u8BF7\u5148\u5728\u547D\u4EE4\u884C\u8F93\u5165 ",(0,t.jsx)("code",{children:"/blackjack agree"}),"\uFF0C\u9605\u8BFB\u5E76\u540C\u610F\u89C4\u5219\u540E\u624D\u80FD\u5F00\u59CB\u3002"]})})}function R({me:e}){return(0,t.jsxs)("div",{className:"bj-meta",children:["\u4F59\u989D\uFF1A",d(e.chips)," CHIP\u3000\u4ECA\u65E5\u5269\u4F59\u514D\u8D39\u624B\uFF1A",e.freeHandsRemaining,e.points>0?`\u3000\u79EF\u5206\uFF1A${d(e.points)}`:"",e.poolEmpty?"\uFF08\u672C\u671F\u5956\u6C60\u5DF2\u53D1\u5B8C\uFF0C\u73B0\u5728\u8D62\u724C\u5C06\u53D1\u653E\u79EF\u5206\uFF09":"",e.sponsorText?(0,t.jsx)("div",{children:e.sponsorText}):null]})}function J({round:e}){if(e.phase!=="settled")return null;let n=e.payoutChips??0;return(0,t.jsxs)("div",{className:"bj-meta",children:[n>0?`\u672C\u624B\u8D62\u5F97 ${d(n)} CHIP\u3002`:"\u672C\u624B\u6CA1\u6709\u6536\u83B7\uFF0C\u660E\u5929\u518D\u6765\u3002",e.pointsGranted?(0,t.jsxs)("div",{children:["\u672C\u671F\u5956\u6C60\u5DF2\u53D1\u5B8C\uFF0C\u6539\u4E3A\u53D1\u653E ",d(e.pointsGranted)," \u79EF\u5206\u3002"]}):null]})}function H({round:e,onAction:n}){return e.actions.length===0?null:(0,t.jsx)("div",{className:"bj-actions",children:e.actions.map(a=>(0,t.jsx)("button",{type:"button",onClick:()=>n(a),children:N[a]??a},a))})}function V({me:e,onDeal:n}){return(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"bj-meta",children:"\u5F53\u524D\u6CA1\u6709\u8FDB\u884C\u4E2D\u7684\u724C\u5C40\u3002"}),(0,t.jsxs)("div",{className:"bj-actions",children:[(0,t.jsx)("button",{type:"button",onClick:()=>n(),children:"\u5F00\u59CB\u4E00\u5C40\uFF08\u9650\u514D\uFF09"}),e?(0,t.jsxs)("button",{type:"button",onClick:()=>n(e.baseBetChips),children:["\u5F00\u59CB\u4E00\u5C40\uFF08",d(e.baseBetChips)," CHIP\uFF09"]}):null]})]})}function m({state:e,onAction:n,onDeal:a,error:i}){if(!e.consented)return(0,t.jsx)(B,{});let{me:o,round:r}=e;return(0,t.jsxs)("div",{className:"bj-table",children:[(0,t.jsx)("style",{children:E}),i?(0,t.jsx)("div",{className:"bj-error",children:i}):null,o?(0,t.jsx)(R,{me:o}):null,r?(0,t.jsxs)("div",{children:[r.hands.map((p,l)=>(0,t.jsx)(A,{hand:p,label:r.hands.length>1?`\u4F60\u7684\u7B2C ${l+1} \u624B`:"\u4F60\u7684\u724C"},l)),(0,t.jsx)(T,{dealer:r.dealer}),(0,t.jsx)(J,{round:r}),(0,t.jsx)(H,{round:r,onAction:n})]}):(0,t.jsx)(V,{me:o,onDeal:a})]})}var f=require("react/jsx-runtime"),I=["slots"];async function k(e,n){let a=await fetch(e,n),i=await a.text(),o=i.length>0?JSON.parse(i):{};if(!a.ok){let r=typeof o?.error=="string"?o.error:`${a.status}`;throw new Error(r)}return o}var X="\u4E0A\u4E00\u6B21\u64CD\u4F5C\u6CA1\u6709\u6210\u529F\uFF0C\u5DF2\u4E3A\u4F60\u5237\u65B0\u5230\u6700\u65B0\u72B6\u6001\u3002";function D(){let[e,n]=(0,s.useState)(null),[a,i]=(0,s.useState)(null),o=(0,s.useCallback)(()=>{k("/blackjack/api/state").then(n).catch(()=>n({consented:!1}))},[]);(0,s.useEffect)(()=>{o()},[o]);let r=(0,s.useCallback)((c,g)=>{k(c,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(g)}).then(()=>{i(null),o()}).catch(()=>{i(X),o()})},[o]),p=(0,s.useCallback)(c=>r("/blackjack/api/deal",c===void 0?{}:{betChips:c}),[r]),l=(0,s.useCallback)(c=>r("/blackjack/api/action",{action:c}),[r]);return e===null?null:(0,f.jsx)(m,{state:e,onAction:l,onDeal:p,error:a??void 0})}function P(e){e.slots.inject("conversation.chat.commandview",()=>e.slots.register({name:"conversation.chat.commandview",key:"blackjack",inject:()=>({})},D))}
41
+ `;function S({text:e}){let n=e.split(/(https?:\/\/[^\s,。))]+)/g);return(0,t.jsx)(t.Fragment,{children:n.map((i,o)=>/^https?:\/\//.test(i)?(0,t.jsx)("a",{href:i,target:"_blank",rel:"noreferrer noopener",children:i},o):i)})}function N({card:e}){if("hidden"in e)return(0,t.jsx)("div",{className:"bj-card bj-card-back","aria-label":"\u6697\u724C"});let n=M[e.suit]??e.suit,i=J.has(e.suit)?"bj-card-red":"bj-card-black";return(0,t.jsx)("div",{className:`bj-card ${i}`,children:`${e.rank}${n}`})}function O({hand:e,label:n}){let i=e.soft?"\u8F6F":"";return(0,t.jsxs)("div",{children:[(0,t.jsxs)("div",{className:"bj-meta",children:[n,"\uFF08",i,e.total,"\uFF09",e.doubled?" [\u53CC\u500D]":""]}),(0,t.jsx)("div",{className:"bj-hand",children:e.cards.map((o,a)=>(0,t.jsx)(N,{card:o},a))})]})}function _({dealer:e}){return(0,t.jsxs)("div",{children:[(0,t.jsxs)("div",{className:"bj-meta",children:["\u5E84\u5BB6",e.total!==void 0?`\uFF08${e.total}\uFF09`:""]}),(0,t.jsx)("div",{className:"bj-hand",children:e.cards.map((n,i)=>(0,t.jsx)(N,{card:n},i))})]})}function U(){return(0,t.jsx)("div",{className:"bj-table",children:(0,t.jsxs)("p",{children:["\u8BF7\u5148\u5728\u547D\u4EE4\u884C\u8F93\u5165 ",(0,t.jsx)("code",{children:"/blackjack agree"}),"\uFF0C\u9605\u8BFB\u5E76\u540C\u610F\u89C4\u5219\u540E\u624D\u80FD\u5F00\u59CB\u3002"]})})}function z({me:e}){let n=e.exchangeThresholdChips-e.chips;return(0,t.jsxs)("div",{className:"bj-meta",children:["\u4F59\u989D\uFF1A",d(e.chips)," CHIP\u3000\u4ECA\u65E5\u5269\u4F59\u514D\u8D39\u624B\uFF1A",e.freeHandsRemaining,e.points>0?`\u3000\u79EF\u5206\uFF1A${d(e.points)}`:"",e.exchangedChips>0?`\u3000\u5DF2\u5151\u6362\u989D\u5EA6\uFF1A${d(e.exchangedChips)} CHIP`:"",(0,t.jsx)("div",{className:"bj-note",children:n<=0?`\u5DF2\u8FBE\u5151\u6362\u95E8\u69DB\uFF08${d(e.exchangeThresholdChips)} CHIP\uFF09\xB7 \u7528 /blackjack exchange \u628A CHIP \u6362\u6210\u6A21\u578B\u989D\u5EA6`:`\u8FD8\u5DEE ${d(n)} CHIP \u5230\u5151\u6362\u95E8\u69DB\uFF08${d(e.exchangeThresholdChips)}\uFF09\xB7 \u8FC7\u7EBF\u540E\u53EF\u7528 /blackjack exchange \u6362\u6210\u6A21\u578B\u989D\u5EA6`}),e.exchangedChips>0?(0,t.jsxs)("div",{className:"bj-note",children:["\u5DF2\u5151\u6362\u7684 ",d(e.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,e.poolEmpty?(0,t.jsx)("div",{children:"\uFF08\u672C\u671F\u5956\u6C60\u5DF2\u53D1\u5B8C\uFF0C\u73B0\u5728\u8D62\u724C\u5C06\u53D1\u653E\u79EF\u5206\uFF09"}):null,e.sponsorText?(0,t.jsx)("div",{children:e.sponsorText}):null]})}function F({round:e}){return(0,t.jsxs)("div",{className:"bj-meta",children:[e.phase==="settled"?w(e):y(e),e.pointsGranted?(0,t.jsxs)("div",{children:["\u672C\u671F\u5956\u6C60\u5DF2\u53D1\u5B8C\uFF0C\u6539\u4E3A\u53D1\u653E ",d(e.pointsGranted)," \u79EF\u5206\u3002"]}):null]})}function G(e,n){return e==="double"||e==="split"?n:e==="insure"?Math.ceil(n/2):0}function q({round:e,me:n,onAction:i}){if(e.actions.length===0)return null;let o=n?.chips??0;return(0,t.jsx)("div",{className:"bj-actions",children:e.actions.map(a=>{let r=G(a,e.betChips),s=r>o;return(0,t.jsxs)("button",{type:"button",disabled:s,title:s?`\u8FD8\u8981\u518D\u51FA ${d(r)} CHIP\uFF0C\u4F59\u989D\u4E0D\u8DB3`:void 0,onClick:()=>i(a),children:[A[a]??a,r>0?`\uFF08${d(r)}\uFF09`:""]},a)})})}function K(e,n){return!Number.isInteger(e)||e<1?"\u6CE8\u989D\u8981\u586B\u6B63\u6574\u6570\u3002":e>n.maxChips?`\u5355\u5C40\u6700\u591A ${d(n.maxChips)} CHIP\u3002`:e>n.balanceChips?"\u4F59\u989D\u4E0D\u8DB3\u4EE5\u4E0B\u8FD9\u4E2A\u6CE8\u3002":null}function Q({me:e,onDeal:n,freeLabel:i}){let o=e?.freeHandsRemaining??0,a=e?[e.baseBetChips,e.baseBetChips*2]:[];return(0,t.jsxs)("div",{className:"bj-actions",children:[(0,t.jsxs)("button",{type:"button",disabled:o<=0,onClick:()=>n(),children:[i,e?`\xB7\u5269 ${o} \u624B`:""]}),a.map(r=>(0,t.jsxs)("button",{type:"button",disabled:(e?.chips??0)<r,onClick:()=>n(r),children:[d(r)," CHIP"]},r))]})}function W({me:e,onDeal:n}){let i=e.maxRaiseBetChips,[o,a]=(0,j.useState)(!1),[r,s]=(0,j.useState)("");if(i===void 0)return null;let u=Number(r),p=r.trim()===""?"\u8BF7\u8F93\u5165\u6CE8\u989D\u3002":K(u,{balanceChips:e.chips,maxChips:i});return(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"bj-actions",children:(0,t.jsx)("button",{type:"button",onClick:()=>a(!o),children:"\u81EA\u5B9A\u4E49\u6CE8\u989D"})}),o?(0,t.jsxs)("div",{className:"bj-custom",children:[(0,t.jsx)("input",{type:"number",min:1,max:i,value:r,onChange:g=>s(g.target.value),"aria-label":"\u81EA\u5B9A\u4E49\u6CE8\u989D"}),(0,t.jsx)("button",{type:"button",disabled:p!==null,onClick:()=>n(u),children:"\u5F00\u59CB"}),(0,t.jsx)("span",{className:"bj-hint",children:p??`1 \uFF5E ${d(i)} CHIP`})]}):null]})}function Y({me:e,onDeal:n}){let i=e?.freeHandsRemaining??0,o=e?.baseBetChips??0,a=e?[o,o*2]:[],r=e?a.some(s=>e.chips>=s):!1;return(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"bj-meta",children:"\u5F53\u524D\u6CA1\u6709\u8FDB\u884C\u4E2D\u7684\u724C\u5C40\u3002"}),(0,t.jsxs)("div",{className:"bj-group",children:[(0,t.jsxs)("div",{className:"bj-group-title",children:["\u514D\u8D39\u624B \xB7 ",i>0?`\u4ECA\u65E5\u5269 ${i} \u624B`:"\u4ECA\u65E5\u5DF2\u7528\u5B8C\uFF0C\u660E\u5929\u518D\u6765"]}),e?(0,t.jsxs)("div",{className:"bj-note",children:["\u4E0D\u7528\u51FA CHIP\u3002\u8D62\u4E86\u62FF ",d(o)," CHIP\uFF1B\u5934\u4E24\u5F20\u724C\u5C31\u51D1\u6210 21 \u70B9\uFF08A \u914D 10/J/Q/K\uFF09\u62FF"," ",d(Math.floor(o*1.5)),"\u3002\u8F93\u4E86\u6216\u5E73\u5C40\u90FD\u4E0D\u635F\u5931\u4EFB\u4F55\u4E1C\u897F\u3002"]}):null,(0,t.jsx)("div",{className:"bj-actions",children:(0,t.jsx)("button",{type:"button",disabled:i<=0,onClick:()=>n(),children:"\u5F00\u59CB\u514D\u8D39\u7684\u4E00\u5C40"})})]}),e?(0,t.jsxs)("div",{className:"bj-group",children:[(0,t.jsxs)("div",{className:"bj-group-title",children:["\u7528\u4F59\u989D\u52A0\u6CE8",r?"":" \xB7 \u4F59\u989D\u4E0D\u8DB3"]}),(0,t.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"}),(0,t.jsx)("div",{className:"bj-actions",children:a.map(s=>(0,t.jsxs)("button",{type:"button",disabled:e.chips<s,onClick:()=>n(s),children:[d(s)," CHIP"]},s))}),(0,t.jsx)(W,{me:e,onDeal:n})]}):null]})}function Z({me:e,onDeal:n,onDismiss:i}){return(0,t.jsxs)("div",{children:[(0,t.jsx)(Q,{me:e,onDeal:n,freeLabel:"\u518D\u6765\u4E00\u5C40\uFF08\u514D\u8D39"}),(0,t.jsx)("div",{className:"bj-actions",children:(0,t.jsx)("button",{type:"button",onClick:i,children:"\u6536\u8D77"})})]})}function T({state:e,onAction:n,onDeal:i,onDismiss:o,error:a}){if(!e.consented)return(0,t.jsx)(U,{});let{me:r,round:s}=e;return(0,t.jsxs)("div",{className:"bj-table",children:[(0,t.jsx)("style",{children:X}),a?(0,t.jsx)("div",{className:"bj-error",children:a}):null,r?(0,t.jsx)(z,{me:r}):null,s?(0,t.jsxs)("div",{children:[s.hands.map((u,p)=>(0,t.jsx)(O,{hand:u,label:s.hands.length>1?`\u4F60\u7684\u7B2C ${p+1} \u624B`:"\u4F60\u7684\u724C"},p)),(0,t.jsx)(_,{dealer:s.dealer}),(0,t.jsx)(F,{round:s}),(0,t.jsx)(q,{round:s,me:r,onAction:n}),s.phase==="settled"?(0,t.jsx)(Z,{me:r,onDeal:i,onDismiss:o}):null]}):(0,t.jsx)(Y,{me:r,onDeal:i})]})}function h(e,n){switch(n.type){case"synced":return{consented:n.payload.consented,me:n.payload.me,round:n.payload.round};case"round":return{...e,round:n.round};case"meRefreshed":return{...e,me:n.me};case"dismissed":return{...e,round:void 0}}}var ee=new Set(["","deal"]);function H(e){let n=(e.args??"").trim().split(/\s+/)[0]??"",i=ee.has(n),o=e.outcome,a=o?.kind==="error";return i?{table:!0,isError:a,...a&&o?.text?{text:o.text}:{}}:{table:!1,isError:a,...o?.text!==void 0?{text:o.text}:{}}}var f=require("react/jsx-runtime"),te=["slots"];async function k(e,n){let i=await fetch(e,n),o=await i.text(),a=o.length>0?JSON.parse(o):{};if(!i.ok){let r=typeof a?.error=="string"?a.error:`${i.status}`;throw new Error(r)}return a}var ne="\u4E0A\u4E00\u6B21\u64CD\u4F5C\u6CA1\u6709\u6210\u529F\uFF0C\u5DF2\u4E3A\u4F60\u5237\u65B0\u5230\u6700\u65B0\u72B6\u6001\u3002";function ie({node:e}){let n=H(e),[i,o]=(0,l.useState)(null),[a,r]=(0,l.useState)(null),s=(0,l.useCallback)(()=>{k("/blackjack/api/state").then(c=>o(b=>h(b??{consented:!1},{type:"synced",payload:c}))).catch(()=>o({consented:!1}))},[]),u=(0,l.useCallback)(()=>{k("/blackjack/api/state").then(c=>o(b=>b&&h(b,{type:"meRefreshed",me:c.me}))).catch(()=>{})},[]);(0,l.useEffect)(()=>{n.table&&s()},[s,n.table]);let p=(0,l.useCallback)((c,b)=>{k(c,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(b)}).then(R=>{r(null),o(v=>v&&h(v,{type:"round",round:R})),u()}).catch(()=>{r(ne),s()})},[u,s]),g=(0,l.useCallback)(c=>p("/blackjack/api/deal",c===void 0?{}:{betChips:c}),[p]),E=(0,l.useCallback)(c=>p("/blackjack/api/action",{action:c}),[p]),I=(0,l.useCallback)(()=>o(c=>c&&h(c,{type:"dismissed"})),[]),x=n.text===void 0?null:(0,f.jsx)("div",{style:{whiteSpace:"pre-wrap",marginBottom:"8px",...n.isError?{color:"#c0392b"}:{}},children:(0,f.jsx)(S,{text:n.text})});return!n.table||i===null?x:(0,f.jsxs)("div",{children:[x,(0,f.jsx)(T,{state:i,onAction:E,onDeal:g,onDismiss:I,error:a??void 0})]})}function oe(e){e.slots.inject("conversation.chat.commandview",()=>e.slots.register({name:"conversation.chat.commandview",key:"blackjack",inject:()=>({})},ie))}
32
42
  return module.exports;
33
43
  }
34
44
  });
package/dist/commands.js CHANGED
@@ -163,12 +163,17 @@ export function registerCommands(ctx, deps) {
163
163
  if (me.githubLogin === null) {
164
164
  const pairing = await api.startPairing(token);
165
165
  const minutes = Math.max(1, Math.round(pairing.expiresInSeconds / 60));
166
+ // 只给链接,不单独列配对码:链接里已经含它了,而单独列出会让人以为要
167
+ // 手动填到某处(运营者第一次用就这么理解了,连开了三次都没敢往下走)。
166
168
  return ok([
167
- '首次兑换需要先在浏览器里绑定 GitHub 账号:',
168
- `配对码:${pairing.code}`,
169
- `链接:${pairing.url}`,
170
- `有效期:约 ${minutes} 分钟`,
171
- '绑定完成后,以后直接用 /blackjack exchange <数量> 就能兑换,不必再开浏览器。',
169
+ '首次兑换要先绑定 GitHub,只需做这一次。',
170
+ '',
171
+ '请在浏览器里打开这个链接:',
172
+ pairing.url,
173
+ '',
174
+ '打开后点「用 GitHub 继续」授权,然后在同一个页面填写要兑换的数量并提交。',
175
+ `链接约 ${minutes} 分钟内有效;过期了就再执行一次 /blackjack exchange 拿一个新的。`,
176
+ '绑定完成后,以后直接用 /blackjack exchange <数量> 兑换,不用再开浏览器。',
172
177
  ].join('\n'));
173
178
  }
174
179
  if (amountArg === undefined) {
@@ -182,9 +187,12 @@ export function registerCommands(ctx, deps) {
182
187
  if (!Number.isInteger(chips) || chips <= 0)
183
188
  return fail('兑换数量必须是正整数。');
184
189
  const result = await api.exchange(token, chips);
190
+ // 必须讲清楚这份额度什么时候会被用上:运营者兑换完就问"我该怎么用"——
191
+ // 因为兑换成功的提示从没说过触发条件,玩家以为兑换完就能直接花。
185
192
  return ok([
186
193
  `已兑换 ${n(result.chips)} CHIP。`,
187
194
  `累计已兑换额度:${n(result.exchangedChips)} CHIP`,
195
+ '这份额度会在你自己的模型额度耗尽时,自动接管那一次失败的请求;平时不会动用,你不需要做任何事。',
188
196
  result.note,
189
197
  ].join('\n'));
190
198
  }
package/dist/copy.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { RoundView } from './api.js';
2
+ /** 这一局的性质与代价。进行中时显示,让玩家随时知道自己在打哪种局。 */
3
+ export declare function stakeLine(round: RoundView): string;
4
+ /**
5
+ * 结算结果。分模式措辞——原先两种局共用一句「本手没有收获,明天再来」,
6
+ * 而加注局只要有余额马上就能再来,那句话是错的。
7
+ */
8
+ export declare function outcomeLine(round: RoundView): string;
package/dist/copy.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 牌局文案的单一来源。命令面(render.ts)与图形牌桌(client/Table.tsx)共用
3
+ * 这里的字符串,否则两边会各说各话——而运营者第一次自己上手时,正是因为
4
+ * 界面没讲清楚"免费手"和"加注"的区别而看不懂在玩什么。
5
+ *
6
+ * 红线(见 render.ts 的 BANNED):单位一律 CHIP,不用「筹码」;不出现赌博词汇。
7
+ * @module dsh-blackjack/copy
8
+ */
9
+ const n = (v) => v.toLocaleString('en-US');
10
+ /** 这一局的性质与代价。进行中时显示,让玩家随时知道自己在打哪种局。 */
11
+ export function stakeLine(round) {
12
+ return round.mode === 'free'
13
+ ? `这是免费的一局:赢了拿 ${n(round.betChips)} CHIP,输了不损失任何东西。`
14
+ : `本局已加注 ${n(round.betChips)} CHIP,从你的余额里出。`;
15
+ }
16
+ /**
17
+ * 结算结果。分模式措辞——原先两种局共用一句「本手没有收获,明天再来」,
18
+ * 而加注局只要有余额马上就能再来,那句话是错的。
19
+ */
20
+ export function outcomeLine(round) {
21
+ const payout = round.payoutChips ?? 0;
22
+ const bet = round.betChips;
23
+ // 加注局平局会原样退回本金,payout > 0 —— 先判平局,否则会被说成赢了。
24
+ if (round.hands.length > 0 && round.hands.every((h) => h.outcome === 'push')) {
25
+ return round.mode === 'free'
26
+ ? '平局。这手不损失也不获得。'
27
+ : `平局。加注的 ${n(bet)} CHIP 原样退回。`;
28
+ }
29
+ if (payout > 0) {
30
+ return round.mode === 'free'
31
+ ? `赢了,拿到 ${n(payout)} CHIP。`
32
+ : `赢了,拿回 ${n(payout)} CHIP(加注 ${n(bet)})。`;
33
+ }
34
+ return round.mode === 'free'
35
+ ? '这手输了。免费手不损失任何东西。'
36
+ : `这手输了,加注的 ${n(bet)} CHIP 收不回。`;
37
+ }
package/dist/render.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { stakeLine, outcomeLine } from './copy.js';
1
2
  const SUITS = { S: '♠', H: '♥', D: '♦', C: '♣' };
2
3
  /**
3
4
  * Regex of vocabulary this project's copy must never contain (parent spec §2
@@ -37,13 +38,14 @@ export function renderRound(view) {
37
38
  lines.push(`庄家:${dealer}${view.dealer.total !== undefined ? `(${view.dealer.total})` : ''}`);
38
39
  if (view.phase === 'settled') {
39
40
  const won = view.payoutChips ?? 0;
40
- lines.push(won > 0 ? `本手赢得 ${n(won)} CHIP。` : '本手没有收获,明天再来。');
41
+ lines.push(outcomeLine(view));
41
42
  if (view.pointsGranted)
42
43
  lines.push(`本期奖池已发完,改为发放 ${n(view.pointsGranted)} 积分。`);
43
44
  if (won > 0)
44
45
  lines.push('(已获得的 CHIP 有效期至本赛季末)');
45
46
  }
46
47
  else {
48
+ lines.push(stakeLine(view));
47
49
  const map = {
48
50
  hit: '/bj-hit 要牌', stand: '/bj-stand 停牌', double: '/bj-double 双倍',
49
51
  split: '/bj-split 分牌', insure: '/bj-insure 买保险', decline: '/bj-decline 不买保险',
package/dist/router.d.ts CHANGED
@@ -27,6 +27,23 @@ export declare function shouldFallback(input: {
27
27
  hasToken: boolean;
28
28
  provider: string;
29
29
  }): boolean;
30
+ /**
31
+ * 这次失败是否该提示玩家"你有已兑换额度,但续命现在用不上"。
32
+ *
33
+ * 续命的触发条件是**你自己的额度耗尽**(QUOTA_EXCEEDED),不是根本没配 key。
34
+ * 运营者兑换到 50,000 CHIP 后问"我该怎么用",正是因为他没配 key、报的是
35
+ * MISSING_CREDENTIAL,于是什么都没发生,也没有任何东西解释为什么。这里只负责
36
+ * 说明,不改变任何行为——原始失败照样原样透传。
37
+ */
38
+ export declare const NO_CREDENTIAL_HINT = "\u7EED\u547D\u53EA\u5728\u4F60\u81EA\u5DF1\u7684\u6A21\u578B\u989D\u5EA6\u8017\u5C3D\u65F6\u63A5\u7BA1\uFF1B\u5F53\u524D\u662F\u6CA1\u6709\u914D\u7F6E key\uFF0C\u6240\u4EE5\u7528\u4E0D\u5230\u4F60\u5DF2\u5151\u6362\u7684\u989D\u5EA6\u3002";
39
+ /** 抛出来的错误是否读作"没配 key"。 */
40
+ export declare function isMissingCredentialError(error: unknown): boolean;
41
+ export declare function shouldHintNoCredential(input: {
42
+ finish: StreamChunk & {
43
+ type: 'finish';
44
+ };
45
+ provider: string;
46
+ }): boolean;
30
47
  export interface FallbackDeps {
31
48
  hasToken: () => Promise<boolean>;
32
49
  poolStream: (options: GenerateOptions) => AsyncIterable<StreamChunk>;
package/dist/router.js CHANGED
@@ -43,6 +43,36 @@ export function shouldFallback(input) {
43
43
  return false;
44
44
  return isQuotaFailure(reason.failure);
45
45
  }
46
+ /**
47
+ * 「没配 key」的错误码。dsh-llm@0.1.0-rc.6 只导出了 INVALID_CREDENTIAL_CODE 和
48
+ * QUOTA_EXCEEDED_CODE,这个没有导出常量——字面值由该包源码里 INVALID_CREDENTIAL
49
+ * 的注释("Distinct from `MISSING_CREDENTIAL`")与宿主 UI 上的徽章共同确认。
50
+ */
51
+ const MISSING_CREDENTIAL_CODE = 'MISSING_CREDENTIAL';
52
+ /**
53
+ * 这次失败是否该提示玩家"你有已兑换额度,但续命现在用不上"。
54
+ *
55
+ * 续命的触发条件是**你自己的额度耗尽**(QUOTA_EXCEEDED),不是根本没配 key。
56
+ * 运营者兑换到 50,000 CHIP 后问"我该怎么用",正是因为他没配 key、报的是
57
+ * MISSING_CREDENTIAL,于是什么都没发生,也没有任何东西解释为什么。这里只负责
58
+ * 说明,不改变任何行为——原始失败照样原样透传。
59
+ */
60
+ export const NO_CREDENTIAL_HINT = '续命只在你自己的模型额度耗尽时接管;当前是没有配置 key,所以用不到你已兑换的额度。';
61
+ /** 抛出来的错误是否读作"没配 key"。 */
62
+ export function isMissingCredentialError(error) {
63
+ const e = error;
64
+ if (e && typeof e === 'object' && e.code === MISSING_CREDENTIAL_CODE)
65
+ return true;
66
+ return typeof e?.message === 'string' && /no API key|MISSING_CREDENTIAL/i.test(e.message);
67
+ }
68
+ export function shouldHintNoCredential(input) {
69
+ if (input.provider === POOL_PROVIDER)
70
+ return false;
71
+ const reason = input.finish.reason;
72
+ if (reason.kind !== 'error')
73
+ return false;
74
+ return reason.failure.code === MISSING_CREDENTIAL_CODE;
75
+ }
46
76
  /**
47
77
  * Wrap one model call: pass everything through, and only when the call ends
48
78
  * with a content-free quota failure retry it against the pool route. Any
@@ -69,18 +99,36 @@ export async function* runWithFallback(options, next, deps) {
69
99
  let emittedContent = false;
70
100
  let finish;
71
101
  const pendingUsage = [];
72
- for await (const c of next()) {
73
- if (c.type === 'finish') {
74
- finish = c;
75
- break;
102
+ try {
103
+ for await (const c of next()) {
104
+ if (c.type === 'finish') {
105
+ finish = c;
106
+ break;
107
+ }
108
+ if (c.type === 'usage') {
109
+ pendingUsage.push(c);
110
+ continue;
111
+ }
112
+ if (CONTENT_TYPES.has(c.type))
113
+ emittedContent = true;
114
+ yield c;
76
115
  }
77
- if (c.type === 'usage') {
78
- pendingUsage.push(c);
79
- continue;
116
+ }
117
+ catch (error) {
118
+ // 没配 key 时适配器在发请求前就抛,根本不产出 finish 分片。只说明,
119
+ // 不改变行为——原始错误原样抛出去,装了这个插件不会让任何调用变差。
120
+ if (options.provider !== POOL_PROVIDER && isMissingCredentialError(error)) {
121
+ let hinted = false;
122
+ try {
123
+ hinted = await deps.hasToken();
124
+ }
125
+ catch {
126
+ hinted = false;
127
+ }
128
+ if (hinted)
129
+ deps.notify(NO_CREDENTIAL_HINT);
80
130
  }
81
- if (CONTENT_TYPES.has(c.type))
82
- emittedContent = true;
83
- yield c;
131
+ throw error;
84
132
  }
85
133
  if (!finish)
86
134
  return;
@@ -90,6 +138,21 @@ export async function* runWithFallback(options, next, deps) {
90
138
  // a credential resolve, and a credentials-provider rejection can only ever
91
139
  // affect a call this plugin was already about to intervene on.
92
140
  if (!shouldFallback({ finish, emittedContent, hasToken: true, provider: options.provider })) {
141
+ // 唯一的例外:没配 key 时,玩家可能正攥着一份用不上的已兑换额度而不知道
142
+ // 为什么。这条只说明、不改行为。await hasToken() 只发生在这个已经失败
143
+ // 且罕见的分支上,正常调用仍然不付凭证解析的代价。
144
+ if (shouldHintNoCredential({ finish, provider: options.provider })) {
145
+ let hinted = false;
146
+ try {
147
+ hinted = await deps.hasToken();
148
+ }
149
+ catch {
150
+ hinted = false;
151
+ }
152
+ if (hinted) {
153
+ deps.notify(NO_CREDENTIAL_HINT);
154
+ }
155
+ }
93
156
  yield* pendingUsage;
94
157
  yield finish;
95
158
  return;
@@ -113,6 +176,11 @@ export async function* runWithFallback(options, next, deps) {
113
176
  try {
114
177
  for await (const c of deps.poolStream({ ...options, provider: POOL_PROVIDER })) {
115
178
  if (c.type === 'finish' && c.reason.kind === 'error') {
179
+ // 奖池这一侧的失败必须留痕。原先它被静默丢弃、连日志都没有,于是
180
+ // "续命明明接管了却还是失败"这种情况从外面完全看不出来——真实事故里
181
+ // 奖池回的是 `unknown model: deepseek-v4-flash`(模型不在价目表里),
182
+ // 而玩家只看到原始的 Insufficient Balance,跟没装插件一模一样。
183
+ deps.logError?.(new Error(`dsh-blackjack: pool retry failed after a quota exhaustion; pool said ${JSON.stringify(c.reason.failure)}`));
116
184
  yield finish;
117
185
  return;
118
186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-blackjack",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "社区福利小游戏插件:在 dsh 里玩 21 点赢取可消费的模型额度",
5
5
  "license": "MIT",
6
6
  "keywords": [