dsh-blackjack
开发工具 活跃维护

dsh-blackjack

yul761/dsh-blackjack

第三方社区福利小游戏,支持在dsh环境内畅玩21点,玩法还原经典规则,获胜可获得仅限本插件使用的模型消费额度,和所有模型厂商无任何关联。

0
Stars 标星
0
Forks 分支
0
Watchers 关注
0
Open Issues
TypeScript
主要语言
MIT
开源协议
718 KB
仓库大小
28 天前
最后推送
一键安装扩展 / 插件指令
dsh plugin --profile web add github:yul761/dsh-blackjack
git clone https://github.com/yul761/dsh-blackjack.git
git clone git@github.com:yul761/dsh-blackjack.git
README.md master

dsh-blackjack

English | 简体中文

⚠️ Third-party community project disclaimer

This project is published and operated by an individual developer and is not
affiliated with any model vendor
: it is not an official project of any vendor,
does not represent their position, is not endorsed or authorized by them, and does
not use their names or logos as branding elements. The dsh- prefix in the project
name merely follows the community's plugin naming convention and identifies the
compatibility target.

The upstream that the metering proxy forwards to is an OpenAI-compatible endpoint
configured by the operator
; whoever deploys this project decides which service to
point it at.

An open-source perk game for the dsh community: developers play blackjack inside
their own dsh, winning CHIP — a game currency funded by the operator. CHIP converts
one-way into "redeemed credit", and that credit can only be spent through this project's
metering proxy. The core experience is: out of credit mid-task? Play a couple of hands
to keep going.

The repository contains two parts:

  • The plugin (installed into each developer's own dsh): the table UI, balance and
    redemption interactions, and the transparent fallback route.
  • The central service (packages/server in this repo, self-hosted by the operator):
    authoritative game state, a four-bucket conservation ledger, the metering proxy, and
    the periodic settlement jobs.

Participation rules (important — read first)

  • Participation is always free. A fixed number of free hands every day, at zero cost.
    There is no design — and never will be — that lets you pay for hands, pay for better
    odds, or pay for better payouts
    .
  • The game currency is one-way and closed. For both CHIP and redeemed credit:
    • not withdrawable, not convertible to any fiat currency, and no monetary amount is
      displayed anywhere in the UI;
    • not transferable (including between players) and non-refundable;
    • never lands in any model vendor's account;
    • the only use is spending it through this project's metering proxy — there is no
      other exit.
  • Inactive balances are recycled. Accounts with no games and no consumption for
    7 consecutive days have their balance recycled into the prize pool (the criterion is
    "played a hand or spent credit"; polling the query endpoints does not count as activity).
  • Redeemed credit does not currently expire. Earlier copy said "valid until the end of
    the season", but season settlement (seasonSettle) never touches redeemed credit at all —
    that sentence did not match the implementation, so it has been changed to describe reality.
    Whether to zero it out at season end is a product decision that has not been made yet; if
    it ever is, it will be announced here and inside the plugin first.
  • Your own API key never enters this system: it is not hosted, not held on your behalf,
    and never used as an in-game resource.
  • Points are only a ranking and honor currency; they have no fixed exchange rate to CHIP.

Installing the plugin

To play inside your own dsh you install packages/plugin (npm package name
dsh-blackjack); you never need to touch the server code:

dsh plugin --profile web add -w dsh-blackjack
dsh --profile web

⚠️ Don't try it on the launch screen right after installing. Running /blackjack
on dsh's launch screen (the "Into the Unknown" view you get when you open a fresh dsh)
actually executes the command, but nothing appears on screen — it looks exactly like
a broken plugin. Send any message first so a conversation genuinely starts, or open an
existing conversation from the sidebar, and then run /blackjack. Clicking "New
conversation" does not help: that sends you back to the launch screen.

This is not a defect in this plugin — the host behaves this way for every command
registered by any plugin
(verified with a minimal probe plugin). See the
plugin README.
Reported upstream: https://github.com/deepseek-ai/deepseek-harness/discussions/4066
(minimal reproduction in docs/repro/dsh-probe-repro/).

Replace web with whichever profile you are actually installing into. The -w flag is
mandatory: a profile directory is itself a pnpm workspace, and without that flag pnpm
refuses to install. The plugin's own full documentation — command table, config options,
what triggers the fallback route, participation rules, how to uninstall — lives in
packages/plugin/README.md.

Compliance and contact

This project operates under the obligation-based requirements of China's Interim Measures
for the Management of Generative AI Services
: open registration, per-request traceable
logging, and a built-in kill switch that can stop service instantly.

  • Compliance enquiries, takedown requests, abuse reports:
    <operator contact email placeholder: replace before deploying>
  • On receiving a request from a regulator or from upstream, the operator will stop service
    immediately with the kill switch (see the runbook below) and respond.

Quick start

Environment variables

Copy .env.example to .env (.env is already in .gitignorenever commit it):

Variable Description
DATABASE_PATH SQLite file path. Defaults to /data/app.db inside the container (on the mounted volume).
PORT HTTP port, defaults to 8787.
ADMIN_TOKEN Credential for the /admin/* endpoints (header X-Admin-Token). If unset, every admin endpoint returns 403.
DEEPSEEK_API_KEY API key for the upstream OpenAI-compatible endpoint (the variable name follows the existing name in the code). Only ever present in the server's environment.
DEEPSEEK_BASE_URL Upstream base URL; the proxy forwards to ${BASE_URL}/chat/completions.
GITHUB_CLIENT_ID client_id of the GitHub OAuth App (a public value, used to build the authorize link on the pairing page). Required for redemption.
GITHUB_CLIENT_SECRET client_secret of the same OAuth App, used only server-side to exchange the authorization code for an access token; it never appears in any response or log. Required for redemption.
PUBLIC_URL The publicly reachable base URL of this service (no trailing slash), used to build the /pair/<code> links. Required for redemption, and in production it must be the real domain.

Redemption config self-check (any one missing stops redemption, not the service): if any
of the three variables marked "required for redemption" above is missing, the redemption flow
would produce a link that looks successful but is unusable — a missing PUBLIC_URL hands
every player http://localhost:8787/pair/<code>, a missing GITHUB_CLIENT_ID builds
authorize?client_id=&…, and a missing GITHUB_CLIENT_SECRET guarantees the callback's token
exchange fails. So the server logs a loud warning at startup listing what is missing, and
POST /api/pairing/start returns 503 across the board (with missingConfig and
operatorHint in the body) rather than handing out a dead code. The game itself (dealing,
actions, balances) and the metering proxy do not depend on these three variables and keep
working normally.

Running locally

pnpm install
pnpm --filter @dsh-blackjack/server test        # full test suite
pnpm --filter @dsh-blackjack/server typecheck
pnpm dev                                        # tsx watch

Docker

docker build -t dsh-blackjack -f packages/server/Dockerfile .
docker run -p 8787:8787 -v "$PWD/data":/data -e ADMIN_TOKEN=... -e DEEPSEEK_API_KEY=... dsh-blackjack

The image runs as the non-root user node (uid 1000), and the database lands in /data by
default.

Deploying on Railway

  1. Create a service pointing at this repository. The railway.json at the repo root already
    points the builder at packages/server/Dockerfile; nothing extra to configure in the panel.
  2. You must mount a persistent volume at /data (railway volume add --mount-path /data).
    Without a volume the database is written into the container's writable layer and every
    redeploy silently loses every player balance
    . DATABASE_PATH already defaults to
    /data/app.db.
  3. The volume is owned by root, which overrides the chown done at image build time. This
    repo's entrypoint handles it: the container starts as root, hands only the data directory
    to node, then drops privileges with setpriv and execs the app. Do not switch to the
    RAILWAY_RUN_UID=0 approach suggested in Railway's docs — that runs the whole app as root
    and throws away the non-root hardening.
  4. Set PORT explicitly to 8787, or change the service domain's target port to the
    PORT Railway injects (8080 by default). When the two disagree the platform shows Online
    while every external request gets a 502.
  5. Configure the environment variables above, then confirm liveness with /api/health. With
    the two GitHub variables missing the service still starts normally; only
    POST /api/pairing/start returns 503 and lists which ones are absent.

Points 2–4 above were all learned the hard way during this project's first real deployment:
Railway's builder outright rejects a VOLUME instruction in a Dockerfile, so persistence
must go through a platform-side volume.

Single-instance constraint (important)

This service can only run as a single process, and that process must own the one SQLite
file exclusively.
The following safety properties all depend on "serialized write
transactions inside one process":

  • the daily free-hand cap and the "only one round in progress at a time" check;
  • the per-player concurrency limit and per-minute rate limit in the metering proxy;
  • atomic transfers in the four-bucket ledger.

Scaling horizontally (multiple replicas / processes / a shared volume) makes each of these
checks count on its own and drains the prize pool outright. Scaling out requires first moving
the ledger onto storage with real cross-process transactions, which is out of scope for v1.

Identity binding: device-code pairing + GitHub OAuth authorization code flow

Redemption requires binding a GitHub account, and one GitHub identity maps to exactly one
player wallet: a player who is already bound cannot rebind (403), and once a GitHub id is
claimed it belongs to that player permanently (binding it to someone else returns 409). This
is the only anti-sockpuppet gate on the prize pool's exit, and the binding rules themselves do
not change.

Binding goes through the full GitHub OAuth authorization code flow, replacing the earlier
approach of "take an access token and call GitHub's user endpoint directly" (that approach did
not verify who issued the token, so a token issued for another application — or leaked from a
dev machine — could be used to bind; that hole is now closed):

  1. The plugin calls POST /api/pairing/start (player-authenticated) and gets a single-use
    pairing code with a short TTL (900 seconds by default, pairing_ttl_seconds) plus a
    ${PUBLIC_URL}/pair/<code> link, and prompts the user to open it in a browser.
  2. The user opens that link in a browser: this step sets an HttpOnly, SameSite=Lax binding
    cookie in that browser, and the page first shows a warning (this will bind your GitHub
    account to a wallet and cannot be rebound; only continue if you generated this link
    yourself, and close it immediately if someone sent it to you) before the "Continue with
    GitHub" button, which redirects to GitHub's official authorize page (the state parameter
    is the pairing code).
  3. GitHub sends the user back to GET /auth/github/callback. The server first checks that the
    cookie on the request matches the hash recorded when that pairing code was bound (a mismatch
    or a missing cookie is a 403 and nothing is written), then uses the
    GITHUB_CLIENT_SECRET — which only the server holds — to exchange the authorization
    code for an access token and read the user identity. No token ever passes through the plugin
    or the user's hands, so there is no question of an untrusted token origin.

    Be precise about what this cookie does and does not prevent: it proves that "the browser
    completing the OAuth callback" is "the browser that opened our own /pair/<code> page". What
    it blocks is an attacker bypassing that page — forging a GitHub authorize link or calling the
    callback URL directly — so that a victim's browser completes a binding without ever having
    touched our domain. What it does not prevent is luring a victim into personally opening
    an attacker-generated /pair/<code> link, seeing our own page with their own eyes, and
    clicking "Continue with GitHub" themselves: the victim's browser picks up the cookie normally
    and passes the check, and their GitHub identity still ends up bound to the attacker's wallet,
    irrevocably under the one-identity-one-wallet rule. The real mitigation for that second
    technique is the in-page warning in step 2 — putting "this binds your identity, and you
    should only continue if you generated this link yourself" in front of the user before they
    click, so they judge the link's provenance themselves, rather than leaning on the cookie
    (which is powerless here by construction — it proves "this browser visited our page", not
    "this browser's owner generated the pairing code").

  4. Once the check passes, the binding is written and the pairing code is rotated into a brand
    new one
    (the original is voided, so the code value that passed through GitHub and browser
    history no longer represents any capability), followed by a 302 to the new code's pairing
    page, where the redemption form is submitted (POST /pair/<new-code>/exchange). The code
    is voided again immediately after a successful redemption
    — the same code cannot be reused
    to redeem repeatedly.

Unknown, expired and already-used pairing codes all return an indistinguishable 404, to prevent
enumeration.

After binding, you never make this trip again. The only reason the browser flow exists is
that GitHub's authorization code flow can only be completed in a browser, and binding only has
to happen once. Once a player is bound, the plugin calls POST /api/exchange directly with the
player token it already holds (binding required, otherwise 403
github-binding-required), which is what /blackjack exchange <amount> does. Both paths share
the same threshold and ledger checks: below the threshold is a 403 below-exchange-threshold
(with thresholdChips in the response), more than the balance is a 402 insufficient-balance,
and neither touches the ledger.

Player-visible error bodies

Every player-visible error has this same shape:

{ "error": { "code": "below-exchange-threshold", "message": "below exchange threshold", "thresholdChips": 30000 } }
  • code is a stable kebab-case identifier and is the contract between the server and the
    plugin
    ; the full list is PLAYER_ERROR_CODES in packages/server/src/http.ts, and that
    table constrains both the parameter type of the server's playerError() and the mapping tests
    on the plugin side, so renaming one without the other fails compilation or tests.
  • message is for logs and curl debugging only. The server does not know the caller's
    language; the wording a player sees always comes from the plugin's own copy tables, looked up
    by code (packages/plugin/src/i18n/).
  • The remaining fields inside error are structured facts (thresholdChips, capChips,
    maxChips, …) for the plugin to word its message with.
  • A missing token, or one the server does not recognize: 401 unknown-player-token.

The metering proxy (POST /v1/chat/completions) is excluded from this: externally it must
keep the OpenAI-compatible { "error": { "message", "type" } } shape — the host's model routing
layer decides whether to fall back based on that type vocabulary, and switching to the shape
above would break it. This inconsistency is deliberate, not an oversight.


Operations runbook

Every tunable parameter lives in the database's config table (not hardcoded, not an
environment variable), is read and written through the admin endpoints, and takes effect
immediately (max_body_bytes is the exception — it needs a process restart):

# Ledger snapshot (four buckets + conservation verdict + kill switch state)
curl -H "X-Admin-Token: $ADMIN_TOKEN" https://<host>/admin/snapshot

# Operator view: DAU, funnel, free/raised round mix, pool flows, and the live value of every config key
curl -H "X-Admin-Token: $ADMIN_TOKEN" https://<host>/admin/metrics

# Change any config key (the key must be a known config key)
curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" -H 'content-type: application/json' \
  -d '{"key":"free_hands_per_day","value":"2"}' https://<host>/admin/config

# Grant credit to an account directly (pool → game balance → redeemed credit, all in one transaction).
# For seed accounts, compensation, and for testing anything (like the fallback route) that needs
# credit to exist first. Editing the database by hand breaks the four-bucket identity and trips the
# daily reconciliation kill switch, so this endpoint is the only way.
curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" -H 'content-type: application/json' \
  -d '{"githubLogin":"someone","amountMicro":300000}' https://<host>/admin/grant
# Also accepts {"playerId":"..."}; returns 402 with the ledger untouched if the pool is short

/admin/config is write-only — after changing something, read it back from the config
field of /admin/metrics to confirm. Writing blind once corrupted several economic parameters
and made the service return 500s.

Fields in /admin/metrics:

Field Meaning
dau Distinct players who opened a round on each of the last 30 days
funnel Registered → played → bound GitHub → redeemed → spent, five levels. The gap between "played" and "bound GitHub" is exactly the population that won but cannot cash out, and is the prize pool's largest source of leakage
rounds Round counts and distinct player counts for free and raised rounds respectively — i.e. whether anyone actually plays raised rounds
poolFlow payoutToFreeMicro / payoutToRaiseMicro (what the pool pays out to each kind of round) and betsToPoolMicro (what players bet back into the pool). Comparing the latter two tells you whether raised rounds are replenishing or draining the pool
config The live value of every config key

Config keys that are easy to get wrong

Key Current value Why it is this value
pool_model deepseek-v4-flash The pool always serves with this model, not the one the caller named. It spends the operator's upstream balance, so whoever pays picks the model; it also eliminates the whole "unknown model" class of failures at the source. The cost: the model shown in the player's UI may differ from the one actually serving, which the plugin README discloses
price_table v4 series, peak tier Upstream has peak and off-peak tiers that differ by 2×, while the ledger can only hold one fixed price. Filling in the off-peak price would let the real bill exceed the consumed_micro the ledger recorded, breaking consumed ≤ deposited; filling in the peak price merely over-charges player credit during off-peak hours, which is safe in the right direction
proxy_est_max_out_tokens 8192 How many output tokens to assume when pre-authorizing. It must not be proxy_max_tokens_cap: when the caller sends no max_tokens that is 131,072, which alone freezes about ¥1.18 — 85% of the total hold — and would force the redemption threshold above ¥1.4
proxy_max_concurrent 4 The turn in which dsh creates a session fires 3 LLM calls at once, not 1 (measured on a real machine on 2026-08-22). With the gate at 1 the other two were rejected with 429, so a player out of credit still saw "insufficient balance" on exactly the first message where the fallback should have caught them. 4 = the 3 measured plus one slot of headroom. The original argument for 1 was "a balance only fits one pre-authorization, so the second would 402 on insufficient balance, which is a vague error" — every player-visible error now carries a stable code that the plugin localizes, and the 402 itself reports need/have, so that concern no longer holds
exchange_threshold_chips 200000 (¥0.40) It must cover one request's pre-authorization (about ¥0.275), not its cost (about ¥0.002–0.05). The deposit-style hold is what has always gated players, not the cost
base_bet_chips 50000 Its ratio to the threshold determines how many wins in a row an all-in needs. The threshold has to land in (2× stake, 4× stake] → exactly two wins (about 18%). Below that bound one win crosses the line; above it three are needed (7.5%), and since a lost all-in zeroes you out, that path becomes a trap
exchange_cap_per_season_chips 3000000 (¥6) Per-player per-season redemption cap, to stop one player emptying the pool. The cumulative total is summed from the ledger over op='exchange' and must not read players.exchanged_micro — that is the current balance, which goes down as credit is spent
register_max_per_ip_per_hour / register_max_per_hour 5 / 30 Rate limits on anonymous registration. The threat is not spending (redemption requires GitHub binding, one identity one wallet) but scripted mass registration farming free hands until the pool is empty and real players see "the prize pool is exhausted". Only a salted hash of the IP is stored. These are starting values and should be tightened once there is real data
inactive_recycle_days 7 A season is only a month long, so 30 days would mean nothing is ever recycled within a season and the settled balances never get handed out

Known divergence between copy and implementation: /api/exchange and earlier copy used to
say redeemed credit was "valid until the end of the season", but seasonSettle never touches
exchanged_micro — in practice it does not expire. The copy has been corrected to describe
reality; whether to zero it out at season end is a product decision that has not been made.

Kill switch

curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" -H 'content-type: application/json' \
  -d '{"on":true}' https://<host>/admin/killswitch
  • Once on, the metering proxy immediately returns 503 for every request (the game and query
    endpoints are unaffected), and the plugin falls back to the player's own configuration.
  • It doubles as the compliance response plan: on a request from a regulator or from
    upstream, pull the switch first and handle it afterwards.
  • The system pulls the switch automatically when: hourly consumption exceeds
    breaker_hourly_micro; the daily reconciliation finds four-bucket conservation violated; or
    the daily reconciliation finds "cost recorded in the logs" and "consumption in the ledger"
    disagree.
  • Reset manually with {"on":false} after troubleshooting; before resetting, read the latest
    reconciliation_reports row.

Periodic jobs

Job Trigger What it does
reconcile Daily at 00:05 Four-bucket conservation assertions + log/ledger cross-check + per-model consumption breakdown, written to reconciliation_reports; pulls the kill switch automatically if it fails
Orphan hold sweep Hourly + at startup Releases pre-authorizations unsettled for over an hour, refunding the balance and freeing the concurrency slot
recycle Daily at 00:15 Recycles the balances of accounts with no games and no consumption for inactive_recycle_days consecutive days back into the pool
season Manual only Season settlement, see below

Manual trigger: curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" https://<host>/admin/jobs/<name>

Season rollover

  1. Pull the kill switch first, to stop new consumption.
  2. Run /admin/jobs/reconcile and confirm ok: true (if it fails, stop and audit the books
    before going further).
  3. Run /admin/jobs/season: game balances below the redemption threshold are converted to points
    at season_carryover_ratio and returned to the pool; balances above it carry over. Redeemed
    credit is untouched — inactivity recycling is what bounds its grace period.
  4. Re-lock this season's exchange rate against the upstream price list as it stands then: update
    chip_rate_micro and price_table.
  5. Update season_id (and optionally sponsor_text), then turn the kill switch off.

Routine checks

  • ok in /admin/snapshot must be true at all times; false is an outage-level problem.
  • consumptionCrossCheck.ok in the latest reconciliation_reports row must likewise be true.
  • The last line of defense is a separate upstream budget account: if every other defense
    fails, the worst outcome is that this round of the event ends early.

License

MIT, see LICENSE. Open-sourcing the server is this project's core asset: every
line of pricing logic in the ledger and the proxy can be audited.