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/LICENSE +21 -0
- package/README.md +189 -0
- package/blackjack.cordis.yml +13 -0
- package/dist/adapter.d.ts +33 -0
- package/dist/adapter.js +58 -0
- package/dist/api.d.ts +85 -0
- package/dist/api.js +55 -0
- package/dist/client/Table.d.ts +30 -0
- package/dist/client/Table.js +74 -0
- package/dist/client/index.d.ts +12 -0
- package/dist/client/index.js +68 -0
- package/dist/client.js +26 -0
- package/dist/commands.d.ts +17 -0
- package/dist/commands.js +262 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +16 -0
- package/dist/identity.d.ts +12 -0
- package/dist/identity.js +28 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +39 -0
- package/dist/render.d.ts +16 -0
- package/dist/render.js +70 -0
- package/dist/router.d.ts +100 -0
- package/dist/router.js +179 -0
- package/dist/routes.d.ts +54 -0
- package/dist/routes.js +195 -0
- package/package.json +95 -0
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;
|
package/dist/commands.js
ADDED
|
@@ -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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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;
|
package/dist/identity.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/render.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/router.d.ts
ADDED
|
@@ -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;
|