dsh-blackjack 0.1.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/dist/client.js ADDED
@@ -0,0 +1,26 @@
1
+ import{useCallback as p,useEffect as T,useState as k}from"react";import{jsx as t,jsxs as r}from"react/jsx-runtime";var v={S:"\u2660",H:"\u2665",D:"\u2666",C:"\u2663"},j=new Set(["H","D"]),d=e=>e.toLocaleString("en-US"),x={hit:"\u8981\u724C",stand:"\u505C\u724C",double:"\u53CC\u500D",split:"\u5206\u724C",insure:"\u4E70\u4FDD\u9669",decline:"\u4E0D\u4E70\u4FDD\u9669"},h=`
2
+ .bj-table { --bj-felt: #0f3d2e; --bj-line: rgba(255,255,255,.18); font-size: 14px; }
3
+ .bj-hand { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin: 4px 0; }
4
+ .bj-card {
5
+ width: 38px; height: 54px; border-radius: 6px; display: flex; align-items: center;
6
+ justify-content: center; font-weight: 700; font-size: 15px; box-shadow: 0 1px 2px rgba(0,0,0,.35);
7
+ background: #fdfdf8; border: 1px solid rgba(0,0,0,.15);
8
+ }
9
+ .bj-card-red { color: #c0392b; }
10
+ .bj-card-black { color: #1a1a1a; }
11
+ .bj-card-back {
12
+ background-image: repeating-linear-gradient(45deg, #2a5aa0, #2a5aa0 4px, #1d4278 4px, #1d4278 8px);
13
+ border: 1px solid rgba(255,255,255,.4);
14
+ }
15
+ .bj-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
16
+ .bj-actions button {
17
+ font: inherit; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--bj-line);
18
+ background: rgba(255,255,255,.08); color: inherit; cursor: pointer;
19
+ }
20
+ .bj-actions button:hover { background: rgba(255,255,255,.16); }
21
+ .bj-meta { opacity: .85; margin-bottom: 8px; }
22
+ .bj-error {
23
+ color: #c0392b; border: 1px solid rgba(192,57,43,.4); border-radius: 6px;
24
+ padding: 4px 8px; margin-bottom: 8px;
25
+ }
26
+ `;function u({card:e}){if("hidden"in e)return t("div",{className:"bj-card bj-card-back","aria-label":"\u6697\u724C"});let n=v[e.suit]??e.suit,a=j.has(e.suit)?"bj-card-red":"bj-card-black";return t("div",{className:`bj-card ${a}`,children:`${e.rank}${n}`})}function S({hand:e,label:n}){let a=e.soft?"\u8F6F":"";return r("div",{children:[r("div",{className:"bj-meta",children:[n,"\uFF08",a,e.total,"\uFF09",e.doubled?" [\u53CC\u500D]":""]}),t("div",{className:"bj-hand",children:e.cards.map((s,o)=>t(u,{card:s},o))})]})}function w({dealer:e}){return r("div",{children:[r("div",{className:"bj-meta",children:["\u5E84\u5BB6",e.total!==void 0?`\uFF08${e.total}\uFF09`:""]}),t("div",{className:"bj-hand",children:e.cards.map((n,a)=>t(u,{card:n},a))})]})}function y(){return t("div",{className:"bj-table",children:r("p",{children:["\u8BF7\u5148\u5728\u547D\u4EE4\u884C\u8F93\u5165 ",t("code",{children:"/blackjack agree"}),"\uFF0C\u9605\u8BFB\u5E76\u540C\u610F\u89C4\u5219\u540E\u624D\u80FD\u5F00\u59CB\u3002"]})})}function C({me:e}){return r("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?t("div",{children:e.sponsorText}):null]})}function N({round:e}){if(e.phase!=="settled")return null;let n=e.payoutChips??0;return r("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?r("div",{children:["\u672C\u671F\u5956\u6C60\u5DF2\u53D1\u5B8C\uFF0C\u6539\u4E3A\u53D1\u653E ",d(e.pointsGranted)," \u79EF\u5206\u3002"]}):null]})}function E({round:e,onAction:n}){return e.actions.length===0?null:t("div",{className:"bj-actions",children:e.actions.map(a=>t("button",{type:"button",onClick:()=>n(a),children:x[a]??a},a))})}function A({me:e,onDeal:n}){return r("div",{children:[t("div",{className:"bj-meta",children:"\u5F53\u524D\u6CA1\u6709\u8FDB\u884C\u4E2D\u7684\u724C\u5C40\u3002"}),r("div",{className:"bj-actions",children:[t("button",{type:"button",onClick:()=>n(),children:"\u5F00\u59CB\u4E00\u5C40\uFF08\u9650\u514D\uFF09"}),e?r("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:s}){if(!e.consented)return t(y,{});let{me:o,round:i}=e;return r("div",{className:"bj-table",children:[t("style",{children:h}),s?t("div",{className:"bj-error",children:s}):null,o?t(C,{me:o}):null,i?r("div",{children:[i.hands.map((b,l)=>t(S,{hand:b,label:i.hands.length>1?`\u4F60\u7684\u7B2C ${l+1} \u624B`:"\u4F60\u7684\u724C"},l)),t(w,{dealer:i.dealer}),t(N,{round:i}),t(E,{round:i,onAction:n})]}):t(A,{me:o,onDeal:a})]})}import{jsx as J}from"react/jsx-runtime";var D=["slots"];async function f(e,n){let a=await fetch(e,n),s=await a.text(),o=s.length>0?JSON.parse(s):{};if(!a.ok){let i=typeof o?.error=="string"?o.error:`${a.status}`;throw new Error(i)}return o}var B="\u4E0A\u4E00\u6B21\u64CD\u4F5C\u6CA1\u6709\u6210\u529F\uFF0C\u5DF2\u4E3A\u4F60\u5237\u65B0\u5230\u6700\u65B0\u72B6\u6001\u3002";function R(){let[e,n]=k(null),[a,s]=k(null),o=p(()=>{f("/blackjack/api/state").then(n).catch(()=>n({consented:!1}))},[]);T(()=>{o()},[o]);let i=p((c,g)=>{f(c,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(g)}).then(()=>{s(null),o()}).catch(()=>{s(B),o()})},[o]),b=p(c=>i("/blackjack/api/deal",c===void 0?{}:{betChips:c}),[i]),l=p(c=>i("/blackjack/api/action",{action:c}),[i]);return e===null?null:J(m,{state:e,onAction:l,onDeal:b,error:a??void 0})}function P(e){e.slots.inject("conversation.chat.commandview",()=>e.slots.register({name:"conversation.chat.commandview",key:"blackjack",inject:()=>({})},R))}export{P as apply,D as inject};
@@ -0,0 +1,17 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { type Api } from './api.js';
3
+ import type { Identity } from './identity.js';
4
+ /**
5
+ * Register the zero-token blackjack table commands. Every handler here runs
6
+ * through `ctx.commands.register`, so its result never enters model history
7
+ * and no player text is ever sent as a session message — the whole point of
8
+ * the table costing nothing.
9
+ *
10
+ * Handlers must never throw: an uncaught throw settles as `command/done`
11
+ * with `kind:'error'` and writes internal detail into the session log, so
12
+ * every branch below is wrapped to translate failures into plain Chinese.
13
+ */
14
+ export declare function registerCommands(ctx: Context, deps: {
15
+ api: Api;
16
+ identity: Identity;
17
+ }): void;
@@ -0,0 +1,262 @@
1
+ import { ApiError } from './api.js';
2
+ import { CONSENT_TEXT, renderMe, renderRound } from './render.js';
3
+ const AGREE_HINT = '请先阅读须知并执行 /blackjack agree 后再使用。';
4
+ const NO_ROUND_HINT = '当前没有进行中的牌局,先用 /blackjack deal 开一局。';
5
+ const USAGE = 'agree | deal [注额] | exchange [数量] | reset';
6
+ const n = (v) => v.toLocaleString('en-US');
7
+ const ACTIONS = [
8
+ { name: 'bj-hit', action: 'hit', description: '21 点:向牌桌要一张牌' },
9
+ { name: 'bj-stand', action: 'stand', description: '21 点:停牌,进入结算' },
10
+ { name: 'bj-double', action: 'double', description: '21 点:加倍注额并再要一张牌' },
11
+ { name: 'bj-split', action: 'split', description: '21 点:把一对相同点数的牌分成两手' },
12
+ { name: 'bj-insure', action: 'insure', description: '21 点:庄家明牌为 A 时购买保险' },
13
+ { name: 'bj-decline', action: 'decline', description: '21 点:不购买保险' },
14
+ ];
15
+ function ok(text) {
16
+ return { kind: 'success', text };
17
+ }
18
+ function fail(text) {
19
+ return { kind: 'error', text };
20
+ }
21
+ /**
22
+ * Register the zero-token blackjack table commands. Every handler here runs
23
+ * through `ctx.commands.register`, so its result never enters model history
24
+ * and no player text is ever sent as a session message — the whole point of
25
+ * the table costing nothing.
26
+ *
27
+ * Handlers must never throw: an uncaught throw settles as `command/done`
28
+ * with `kind:'error'` and writes internal detail into the session log, so
29
+ * every branch below is wrapped to translate failures into plain Chinese.
30
+ */
31
+ export function registerCommands(ctx, deps) {
32
+ const { api, identity } = deps;
33
+ // Cheap in-memory hint for "which round is active" so most actions skip a
34
+ // round-trip; always falls back to the server (api.activeRound) when this
35
+ // is empty, e.g. right after a process restart.
36
+ let activeRoundId;
37
+ function noteRound(round) {
38
+ activeRoundId = round.phase === 'settled' ? undefined : round.id;
39
+ }
40
+ /** Translate a caught failure into player-safe Chinese. Never leaks status codes, URLs, or stack traces. */
41
+ async function mapError(e, token) {
42
+ if (!(e instanceof ApiError))
43
+ return fail('操作没有成功,请稍后再试。');
44
+ if (e.status === 0 || e.status >= 500)
45
+ return fail('暂时连不上牌桌服务,请稍后再试。');
46
+ if (e.status === 403 && e.code === 'no free hands left')
47
+ return fail('今天的免费手用完了,明天再来。');
48
+ if (e.status === 403 && e.code === 'below threshold')
49
+ return fail('余额还没到兑换门槛。');
50
+ if (e.status === 402)
51
+ return fail('余额不足以完成这个动作。');
52
+ // 404 = 这局在服务端已经不存在了(最常见的来路:玩家刚在图形牌桌上把它打
53
+ // 完,而这里的 activeRoundId 还停在旧 id 上)。缓存必须清掉,否则下一条动作
54
+ // 命令会拿着同一个死 id 再撞一次;提示也要说清楚该怎么办,而不是给一句通用
55
+ // 的"操作没有成功"。
56
+ if (e.status === 404) {
57
+ activeRoundId = undefined;
58
+ return fail(NO_ROUND_HINT);
59
+ }
60
+ if (e.status === 409) {
61
+ if (token) {
62
+ try {
63
+ const round = await api.activeRound(token);
64
+ if (round) {
65
+ noteRound(round);
66
+ return fail(['已经有一局在进行中。', renderRound(round)].join('\n'));
67
+ }
68
+ }
69
+ catch {
70
+ // fall through to the plain message below — never let a nested
71
+ // failure here mask the original 409.
72
+ }
73
+ }
74
+ return fail('已经有一局在进行中。');
75
+ }
76
+ return fail('操作没有成功,请稍后再试。');
77
+ }
78
+ /** Resolve the bearer token, guiding to consent when absent, then run `body` catching every failure. */
79
+ async function withToken(body) {
80
+ const token = await identity.current();
81
+ if (!token)
82
+ return fail(AGREE_HINT);
83
+ try {
84
+ return await body(token);
85
+ }
86
+ catch (e) {
87
+ return mapError(e, token);
88
+ }
89
+ }
90
+ /** Memory-first, server-fallback lookup for the id an action command needs. */
91
+ async function currentRoundId(token) {
92
+ if (activeRoundId)
93
+ return activeRoundId;
94
+ const round = await api.activeRound(token);
95
+ if (!round)
96
+ return undefined;
97
+ noteRound(round);
98
+ return round.id;
99
+ }
100
+ async function showStatus() {
101
+ const token = await identity.current();
102
+ if (!token)
103
+ return ok(CONSENT_TEXT);
104
+ try {
105
+ const round = await api.activeRound(token);
106
+ if (round) {
107
+ noteRound(round);
108
+ return ok(renderRound(round));
109
+ }
110
+ activeRoundId = undefined;
111
+ const me = await api.me(token);
112
+ return ok(renderMe(me));
113
+ }
114
+ catch (e) {
115
+ return mapError(e, token);
116
+ }
117
+ }
118
+ async function doAgree() {
119
+ try {
120
+ await identity.accept();
121
+ return ok([
122
+ '已确认须知,欢迎上桌。',
123
+ '/blackjack deal 开始今天的免费手,或 /blackjack deal <注额> 用余额加注。',
124
+ ].join('\n'));
125
+ }
126
+ catch (e) {
127
+ return mapError(e);
128
+ }
129
+ }
130
+ async function doDeal(token, betArg) {
131
+ const body = betArg === undefined
132
+ ? { mode: 'free' }
133
+ : (() => {
134
+ const bet = Number(betArg);
135
+ if (!Number.isInteger(bet) || bet <= 0)
136
+ return undefined;
137
+ return { mode: 'raise', betChips: bet };
138
+ })();
139
+ if (!body)
140
+ return fail('注额必须是正整数。');
141
+ const round = await api.startRound(token, body);
142
+ noteRound(round);
143
+ return ok(renderRound(round));
144
+ }
145
+ /**
146
+ * Exchange has TWO paths, and which one applies is a property of the player,
147
+ * not of the command:
148
+ *
149
+ * - **Never bound** (`me.githubLogin === null`): the browser dance is
150
+ * unavoidable — only a browser can complete GitHub's authorization-code
151
+ * flow — so hand back a pairing code and link.
152
+ * - **Already bound**: `POST /api/exchange` does it directly, in one command,
153
+ * with no link to open. Routing an already-bound player through pairing
154
+ * anyway would be busywork: a fresh code, a browser tab, and a form, all to
155
+ * reach an endpoint the plugin can already call with the token it holds.
156
+ *
157
+ * The bound path needs an amount, because unlike the pairing page there is no
158
+ * form to fill in afterwards; asking for it up front beats guessing (an
159
+ * implicit "exchange everything" would be irreversible and unasked-for).
160
+ */
161
+ async function doExchange(token, amountArg) {
162
+ const me = await api.me(token);
163
+ if (me.githubLogin === null) {
164
+ const pairing = await api.startPairing(token);
165
+ const minutes = Math.max(1, Math.round(pairing.expiresInSeconds / 60));
166
+ return ok([
167
+ '首次兑换需要先在浏览器里绑定 GitHub 账号:',
168
+ `配对码:${pairing.code}`,
169
+ `链接:${pairing.url}`,
170
+ `有效期:约 ${minutes} 分钟`,
171
+ '绑定完成后,以后直接用 /blackjack exchange <数量> 就能兑换,不必再开浏览器。',
172
+ ].join('\n'));
173
+ }
174
+ if (amountArg === undefined) {
175
+ return fail([
176
+ `已绑定 GitHub 账号:${me.githubLogin}`,
177
+ `当前余额:${n(me.chips)} CHIP 兑换门槛:${n(me.exchangeThresholdChips)} CHIP`,
178
+ '请用 /blackjack exchange <数量> 指定要兑换的 CHIP 数量。',
179
+ ].join('\n'));
180
+ }
181
+ const chips = Number(amountArg);
182
+ if (!Number.isInteger(chips) || chips <= 0)
183
+ return fail('兑换数量必须是正整数。');
184
+ const result = await api.exchange(token, chips);
185
+ return ok([
186
+ `已兑换 ${n(result.chips)} CHIP。`,
187
+ `累计已兑换额度:${n(result.exchangedChips)} CHIP`,
188
+ result.note,
189
+ ].join('\n'));
190
+ }
191
+ async function doReset(confirmArg) {
192
+ if (confirmArg !== 'confirm') {
193
+ return ok('这会清除本机保存的牌桌凭证(不影响服务端记录,重新配对即可找回)。确认请执行 /blackjack reset confirm');
194
+ }
195
+ try {
196
+ await identity.clear();
197
+ activeRoundId = undefined;
198
+ return ok('已清除本机凭证,可用 /blackjack agree 重新开始。');
199
+ }
200
+ catch (e) {
201
+ return mapError(e);
202
+ }
203
+ }
204
+ // The six bj-* commands register `doAction` directly as their handler —
205
+ // unlike blackjackHandler, there is no outer try/catch wrapping the
206
+ // registration, so this function's own try/catch must cover EVERY await
207
+ // in its body, including the token fetch. `identity.current()` resolves
208
+ // through the credentials service, which a file-backed provider can throw
209
+ // from (I/O or parse error); a throw here must never escape past this
210
+ // function, or the command runtime records it verbatim (with internal
211
+ // detail) into the session log instead of the generic failure text.
212
+ async function doAction(action) {
213
+ let token;
214
+ try {
215
+ token = await identity.current();
216
+ if (!token)
217
+ return fail(AGREE_HINT);
218
+ const id = await currentRoundId(token);
219
+ if (!id)
220
+ return fail(NO_ROUND_HINT);
221
+ const result = await api.act(token, id, action);
222
+ noteRound(result);
223
+ return ok(renderRound(result));
224
+ }
225
+ catch (e) {
226
+ return mapError(e, token);
227
+ }
228
+ }
229
+ async function blackjackHandler(inv) {
230
+ try {
231
+ const parts = inv.rawInput.trim().split(/\s+/).filter(Boolean);
232
+ const sub = parts[0] ?? '';
233
+ switch (sub) {
234
+ case '': return await showStatus();
235
+ case 'agree': return await doAgree();
236
+ case 'deal': return await withToken((token) => doDeal(token, parts[1]));
237
+ case 'exchange': return await withToken((token) => doExchange(token, parts[1]));
238
+ case 'reset': return await doReset(parts[1]);
239
+ default: return fail(`未知子命令「${sub}」。可用:${USAGE}`);
240
+ }
241
+ }
242
+ catch (e) {
243
+ // Belt-and-suspenders: every branch above already catches, but a
244
+ // handler must never throw — a thrown error would be recorded verbatim
245
+ // into the session log by the command runtime.
246
+ return mapError(e);
247
+ }
248
+ }
249
+ ctx.commands.register({
250
+ name: 'blackjack',
251
+ description: '打开 21 点牌桌:查看状态、开局、兑换额度、重置本机凭证',
252
+ input: { hint: USAGE },
253
+ handler: blackjackHandler,
254
+ });
255
+ for (const { name, action, description } of ACTIONS) {
256
+ ctx.commands.register({
257
+ name,
258
+ description,
259
+ handler: () => doAction(action),
260
+ });
261
+ }
262
+ }
@@ -0,0 +1,13 @@
1
+ import Schema from '@deepseek-ai/schemastery';
2
+ /** Cordis plugin name, also used in loader diagnostics and log lines. */
3
+ export declare const PLUGIN_NAME = "dsh-blackjack";
4
+ /**
5
+ * Credential reference holding this player's server-issued bearer token.
6
+ * A POSIX env-var-shaped name, as `CredentialRef` requires.
7
+ */
8
+ export declare const TOKEN_REF = "DSH_BLACKJACK_TOKEN";
9
+ export interface ConfigType {
10
+ serverUrl: string;
11
+ enabled: boolean;
12
+ }
13
+ export declare const Config: Schema<Partial<ConfigType>, ConfigType>;
package/dist/config.js ADDED
@@ -0,0 +1,16 @@
1
+ import Schema from '@deepseek-ai/schemastery';
2
+ /** Cordis plugin name, also used in loader diagnostics and log lines. */
3
+ export const PLUGIN_NAME = 'dsh-blackjack';
4
+ /**
5
+ * Credential reference holding this player's server-issued bearer token.
6
+ * A POSIX env-var-shaped name, as `CredentialRef` requires.
7
+ */
8
+ export const TOKEN_REF = 'DSH_BLACKJACK_TOKEN';
9
+ export const Config = Schema.object({
10
+ // 默认指向本项目运营者自托管的那一份服务。想自建奖池的人把这一项改成自己的
11
+ // 地址即可 —— 服务端与插件同仓开源,自托管是一等公民(见仓库 README)。
12
+ serverUrl: Schema.string()
13
+ .default('https://server-production-493c.up.railway.app')
14
+ .description('牌局服务端地址;自建奖池时改成你自己的部署地址'),
15
+ enabled: Schema.boolean().default(true).description('停用后不注册命令也不挂路由'),
16
+ });
@@ -0,0 +1,12 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { Api } from './api.js';
3
+ export interface Identity {
4
+ current(): Promise<string | undefined>;
5
+ accept(): Promise<string>;
6
+ clear(): Promise<void>;
7
+ }
8
+ /**
9
+ * The player's server-issued token doubles as the consent record: no token
10
+ * means the one-time notice has not been accepted yet.
11
+ */
12
+ export declare function createIdentity(ctx: Context, api: Api): Identity;
@@ -0,0 +1,28 @@
1
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
2
+ import { TOKEN_REF } from './config.js';
3
+ /**
4
+ * The player's server-issued token doubles as the consent record: no token
5
+ * means the one-time notice has not been accepted yet.
6
+ */
7
+ export function createIdentity(ctx, api) {
8
+ const ref = credentialRef(TOKEN_REF);
9
+ async function current() {
10
+ const resolved = await ctx.credentials.resolve(ref);
11
+ const value = resolved?.value;
12
+ return value ? value : undefined;
13
+ }
14
+ return {
15
+ current,
16
+ async accept() {
17
+ const existing = await current();
18
+ if (existing)
19
+ return existing;
20
+ const { token } = await api.register();
21
+ await ctx.credentials.set(ref, token);
22
+ return token;
23
+ },
24
+ async clear() {
25
+ await ctx.credentials.unset(ref);
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,14 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { Config, type ConfigType } from './config.js';
3
+ export declare const name = "dsh-blackjack";
4
+ /**
5
+ * Services this plugin genuinely reads:
6
+ * - `commands` — the zero-token table entry point.
7
+ * - `llm` — adapter registration plus the `llm/stream` fallback waterfall.
8
+ * - `credentials` — the player's server-issued token.
9
+ * The web pieces (HTTP routes, table component) are a NESTED plugin injecting
10
+ * `webServer`, so a headless host leaves that fiber pending instead of failing.
11
+ */
12
+ export declare const inject: string[];
13
+ export { Config };
14
+ export declare function apply(ctx: Context, config: ConfigType): void;
package/dist/index.js ADDED
@@ -0,0 +1,39 @@
1
+ import { createApi } from './api.js';
2
+ import { registerCommands } from './commands.js';
3
+ import { Config, PLUGIN_NAME } from './config.js';
4
+ import { createIdentity } from './identity.js';
5
+ import { registerRouter } from './router.js';
6
+ import { registerRoutes } from './routes.js';
7
+ export const name = PLUGIN_NAME;
8
+ /**
9
+ * Services this plugin genuinely reads:
10
+ * - `commands` — the zero-token table entry point.
11
+ * - `llm` — adapter registration plus the `llm/stream` fallback waterfall.
12
+ * - `credentials` — the player's server-issued token.
13
+ * The web pieces (HTTP routes, table component) are a NESTED plugin injecting
14
+ * `webServer`, so a headless host leaves that fiber pending instead of failing.
15
+ */
16
+ export const inject = ['commands', 'llm', 'credentials'];
17
+ export { Config };
18
+ export function apply(ctx, config) {
19
+ if (!config.enabled)
20
+ return;
21
+ const api = createApi({ serverUrl: config.serverUrl });
22
+ const identity = createIdentity(ctx, api);
23
+ registerCommands(ctx, { api, identity });
24
+ // The switch notice goes to ctx.logger.info and nowhere else — never a
25
+ // session message, so a fallback can never pollute model-visible
26
+ // conversation history. That also means the player does not normally SEE
27
+ // it; see the known-limitation note on `registerRouter` for why no
28
+ // in-conversation channel is reachable from a waterfall listener here.
29
+ registerRouter(ctx, { serverUrl: config.serverUrl, identity });
30
+ // The graphical table's HTTP surface is a NESTED plugin injecting
31
+ // `webServer` — a headless/tui host simply leaves this fiber pending
32
+ // (no `webServer` service to satisfy the inject) while the commands
33
+ // above keep working unaffected.
34
+ ctx.plugin({
35
+ name: 'dsh-blackjack-web',
36
+ inject: ['webServer'],
37
+ apply: (webCtx) => registerRoutes(webCtx, { api, identity }),
38
+ });
39
+ }
@@ -0,0 +1,16 @@
1
+ import type { Card, HiddenCard, MeView, RoundView } from './api.js';
2
+ /**
3
+ * Regex of vocabulary this project's copy must never contain (parent spec §2
4
+ * red line 6, 文案去赌场化).
5
+ *
6
+ * THE single definition for the whole repo, not just this package: the
7
+ * server's `test/pairing.test.ts` imports it across the workspace (test-only,
8
+ * via a relative path) so the plugin's command output and the server's pairing
9
+ * HTML are held to the exact same list. It used to be duplicated inline there,
10
+ * which meant tightening one copy silently left the other looser.
11
+ */
12
+ export declare const BANNED: RegExp;
13
+ export declare const CONSENT_TEXT: string;
14
+ export declare function renderCard(card: Card | HiddenCard): string;
15
+ export declare function renderRound(view: RoundView): string;
16
+ export declare function renderMe(me: MeView): string;
package/dist/render.js ADDED
@@ -0,0 +1,70 @@
1
+ const SUITS = { S: '♠', H: '♥', D: '♦', C: '♣' };
2
+ /**
3
+ * Regex of vocabulary this project's copy must never contain (parent spec §2
4
+ * red line 6, 文案去赌场化).
5
+ *
6
+ * THE single definition for the whole repo, not just this package: the
7
+ * server's `test/pairing.test.ts` imports it across the workspace (test-only,
8
+ * via a relative path) so the plugin's command output and the server's pairing
9
+ * HTML are held to the exact same list. It used to be duplicated inline there,
10
+ * which meant tightening one copy silently left the other looser.
11
+ */
12
+ export const BANNED = /赌|博彩|casino|赢钱|下注赢|筹码/i;
13
+ export const CONSENT_TEXT = [
14
+ '【首次使用请先阅读】',
15
+ '· 这是社区开发者发布的第三方小游戏,与任何模型厂商均无关联,不代表其立场。',
16
+ '· 参与免费:每日有限次免费手,输了不扣任何东西,你自己的密钥与余额不受影响。',
17
+ '· 赢得的 CHIP 只能在本插件内兑换为模型额度消费:不可提现、不可转让、不可转入任何厂商账户。',
18
+ '· 奖励有赛季有效期,赛季结束后未使用的部分按规则回收。',
19
+ '',
20
+ '同意以上内容后执行 /blackjack agree 开始。',
21
+ ].join('\n');
22
+ export function renderCard(card) {
23
+ if ('hidden' in card)
24
+ return '??';
25
+ return `${card.rank}${SUITS[card.suit] ?? card.suit}`;
26
+ }
27
+ const n = (v) => v.toLocaleString('en-US');
28
+ export function renderRound(view) {
29
+ const lines = [];
30
+ for (const [i, hand] of view.hands.entries()) {
31
+ const label = view.hands.length > 1 ? `你的第 ${i + 1} 手` : '你的牌';
32
+ const cards = hand.cards.map(renderCard).join(' ');
33
+ const soft = hand.soft ? '软' : '';
34
+ lines.push(`${label}:${cards}(${soft}${hand.total})${hand.doubled ? ' [双倍]' : ''}`);
35
+ }
36
+ const dealer = view.dealer.cards.map(renderCard).join(' ');
37
+ lines.push(`庄家:${dealer}${view.dealer.total !== undefined ? `(${view.dealer.total})` : ''}`);
38
+ if (view.phase === 'settled') {
39
+ const won = view.payoutChips ?? 0;
40
+ lines.push(won > 0 ? `本手赢得 ${n(won)} CHIP。` : '本手没有收获,明天再来。');
41
+ if (view.pointsGranted)
42
+ lines.push(`本期奖池已发完,改为发放 ${n(view.pointsGranted)} 积分。`);
43
+ if (won > 0)
44
+ lines.push('(已获得的 CHIP 有效期至本赛季末)');
45
+ }
46
+ else {
47
+ const map = {
48
+ hit: '/bj-hit 要牌', stand: '/bj-stand 停牌', double: '/bj-double 双倍',
49
+ split: '/bj-split 分牌', insure: '/bj-insure 买保险', decline: '/bj-decline 不买保险',
50
+ };
51
+ const actions = view.actions.map((a) => map[a] ?? a).join(' ');
52
+ if (actions)
53
+ lines.push(`可选:${actions}`);
54
+ }
55
+ return lines.join('\n');
56
+ }
57
+ export function renderMe(me) {
58
+ const lines = [
59
+ `余额:${n(me.chips)} CHIP 已兑换额度:${n(me.exchangedChips)} CHIP`,
60
+ `今日剩余免费手:${me.freeHandsRemaining} 赛季:${me.seasonId}`,
61
+ `兑换门槛:${n(me.exchangeThresholdChips)} CHIP(达标后可用 /blackjack exchange 兑换)`,
62
+ ];
63
+ if (me.points > 0)
64
+ lines.push(`积分:${n(me.points)}`);
65
+ if (me.poolEmpty)
66
+ lines.push('提示:本期奖池已发完,现在赢牌将发放积分。');
67
+ if (me.sponsorText)
68
+ lines.push(me.sponsorText);
69
+ return lines.join('\n');
70
+ }
@@ -0,0 +1,100 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm';
3
+ import type { Identity } from './identity.js';
4
+ /** Provider route the pool adapter is registered under; also the loop-guard for `shouldFallback`. */
5
+ export declare const POOL_PROVIDER = "blackjack-pool";
6
+ /**
7
+ * Decide whether one finished call should be retried on the pool route.
8
+ *
9
+ * NOTE on `QUOTA_EXCEEDED_CODE` vs `isQuotaExceededError`: the brief's
10
+ * original sketch called `isQuotaExceededError(reason.failure)` directly on
11
+ * the failure object. Checking the installed `@deepseek-ai/dsh-llm@0.1.0-rc.6`
12
+ * package directly shows `isQuotaExceededError(detail: string): boolean` is a
13
+ * *raw provider wording* classifier (regexes over free text such as
14
+ * "insufficient quota") — it is what an adapter uses internally while
15
+ * normalizing a thrown provider error into a `LlmFailure`, not something a
16
+ * downstream consumer applies to an already-normalized `finish` chunk as-is.
17
+ * The normalized signal to compare first is `LlmFailure.code` against
18
+ * `QUOTA_EXCEEDED_CODE`; `isQuotaExceededError` is kept only as a secondary,
19
+ * string-joined fallback for adapters that never normalized at all (see
20
+ * `isQuotaFailure` above).
21
+ */
22
+ export declare function shouldFallback(input: {
23
+ finish: StreamChunk & {
24
+ type: 'finish';
25
+ };
26
+ emittedContent: boolean;
27
+ hasToken: boolean;
28
+ provider: string;
29
+ }): boolean;
30
+ export interface FallbackDeps {
31
+ hasToken: () => Promise<boolean>;
32
+ poolStream: (options: GenerateOptions) => AsyncIterable<StreamChunk>;
33
+ notify: (line: string) => void;
34
+ /** Diagnostic-only sink for a swallowed pool-retry failure; never required, never surfaced to the player. */
35
+ logError?: (error: unknown) => void;
36
+ }
37
+ /**
38
+ * Wrap one model call: pass everything through, and only when the call ends
39
+ * with a content-free quota failure retry it against the pool route. Any
40
+ * failure of that retry surfaces the ORIGINAL failure, so installing this
41
+ * plugin can never make a call worse than not having it.
42
+ *
43
+ * Two invariants the pool-retry branch upholds that are easy to get wrong:
44
+ * - The stream ALWAYS ends with exactly one terminal `finish` chunk. A pool
45
+ * retry that throws mid-stream (after already forwarding content), or one
46
+ * that simply ends without ever sending its own finish, would otherwise
47
+ * leave the consumer's `BlockAssembler` defaulting an absent finish to
48
+ * `{kind: 'stop'}` — silently presenting a FAILED fallback as a successful,
49
+ * truncated turn. `poolFinished` tracks whether the pool ever produced its
50
+ * own terminal finish; whenever it did not, the ORIGINAL failure is yielded
51
+ * as the terminal chunk instead (legal even after open content blocks,
52
+ * since its `reason.kind` is `'error'`).
53
+ * - `usage` never gets forwarded twice. Adapters emit at most one `usage`
54
+ * chunk, just before their finish. The primary attempt's `usage` (if any)
55
+ * is buffered rather than forwarded immediately, so it can be dropped
56
+ * entirely once a fallback is underway — the pool call reports its own —
57
+ * instead of both being forwarded and double-counting tokens.
58
+ */
59
+ export declare function runWithFallback(options: GenerateOptions, next: () => AsyncIterable<StreamChunk>, deps: FallbackDeps): AsyncIterable<StreamChunk>;
60
+ /**
61
+ * Register the pool-route adapter and hang the `llm/stream` fallback
62
+ * waterfall off it.
63
+ *
64
+ * KNOWN LIMITATION — where the switch notice actually goes. `notify` defaults
65
+ * to `ctx.logger.info`, which means the player normally never SEES it: in dsh
66
+ * 0.1.0-rc.6 a waterfall listener has no FIRE-AND-FORGET notice channel it may
67
+ * use. Everything reachable from here was checked (see the plugin spec's §7
68
+ * appendix for the full record): no host service in this plugin's dependency
69
+ * closure is a notification sink (`agents`, `sessions`, `credentials`,
70
+ * `commands`, `llm`, `webServer`, `conversation`, `chatFileMentions`). The one
71
+ * human-visible in-conversation seam that does exist elsewhere in the host,
72
+ * `ctx.userQuestions.ask()`, BLOCKS on a human answer and needs a GUI
73
+ * provider — wrong semantics for a passive notice, and it would stall the very
74
+ * stream this fallback is rescuing. `commands` only runs when the player types
75
+ * a slash command;
76
+ * `agent.inject()` writes a model-visible `user/message`, which is exactly
77
+ * what this project forbids; and `SurfaceEventType` is a CLOSED three-member
78
+ * set, so the UI's generic unclaimed-event fallback covers only
79
+ * model-visible events — a log-only custom event has no renderer at all.
80
+ * Surfacing it would take a plugin-owned `ConversationNodeDefinition` in the
81
+ * client bundle, i.e. a new feature that still would not reach TUI/headless
82
+ * players. The README says plainly that the notice lands in the log, rather
83
+ * than promising an on-screen one. Revisit when dsh grows a plugin-usable
84
+ * in-session notice slot.
85
+ *
86
+ * What is NOT negotiable either way: the notice must never be injected into
87
+ * model messages, so a fallback can never pollute conversation history.
88
+ *
89
+ * `ctx.llm.registerAdapter()` already ties its disposal to the calling
90
+ * fiber (cordis re-binds a service's `this.ctx` to whichever context reads
91
+ * it, so the registration's own internal `ctx.effect` lands on OUR plugin's
92
+ * fiber, not `llm`'s construction context — confirmed by disposing a probe
93
+ * fiber and observing the route drop from `listProviders()` with no extra
94
+ * wrapping). No separate `ctx.effect` around `registration` is needed.
95
+ */
96
+ export declare function registerRouter(ctx: Context, deps: {
97
+ serverUrl: string;
98
+ identity: Identity;
99
+ notify?: (line: string) => void;
100
+ }): void;