Author SHA1 Message Date
JWeBot Contributors afc71e63a7 Release beta 1.0.0 baseline
check / local-gate (push) Failing after 1m35s
2026-10-16 01:07:07 +08:00
JWeBot Contributors bf4f28f48e Add coin leaderboard command
check / local-gate (push) Failing after 1m34s
2026-10-15 19:00:29 +08:00
JWeBot Contributors 148ae672f4 Install autochess data in runtime
check / local-gate (push) Failing after 1m39s
2026-10-15 18:48:48 +08:00
JWeBot Contributors bbddb5be6f Add virtual coin loans
check / local-gate (push) Failing after 42s
2026-10-15 18:43:49 +08:00
JWeBot Contributors 2bb2effc04 Externalize autochess game data
check / local-gate (push) Failing after 58s
2026-10-15 18:37:41 +08:00
JWeBot Contributors 5125b827c0 Release alpha 0.6.8 autochess legacy state fix
check / local-gate (push) Failing after 1m33s
2026-10-15 18:18:00 +08:00
JWeBot Contributors 3dea42b268 Release alpha 0.6.7 autochess pacing
check / local-gate (push) Failing after 32s
2026-10-15 18:08:55 +08:00
JWeBot Contributors a74147e7f1 Release alpha 0.6.6 autochess flow
check / local-gate (push) Failing after 40s
2026-10-15 17:10:28 +08:00
JWeBot Contributors 9fff57134f feat: add two-human AI autochess mode
check / local-gate (push) Failing after 50s
2026-10-15 16:08:59 +08:00
JWeBot Contributors e713ff369a feat: add CS Major autochess tournament
check / local-gate (push) Failing after 2m1s
2026-10-15 15:17:32 +08:00
108 changed files with 10149 additions and 116 deletions
+1
View File
@@ -67,6 +67,7 @@ id_ed25519*
# Sensitive local notes # Sensitive local notes
homeserver-apikey-*.md homeserver-apikey-*.md
debug-*.md
# macOS/editor # macOS/editor
.DS_Store .DS_Store
+4 -2
View File
@@ -4,9 +4,11 @@ These rules are mandatory for every automated change in this repository.
## Versioning ## Versioning
- Use `alpha X.Y.Z.MMDD`. - Use `beta X.Y.Z.MMDD` for current beta releases.
- Python package versions use PEP 440 pre-release form, for example
`X.Y.Zb1+MMDD`.
- Small changes increment only `Z`. - Small changes increment only `Z`.
- Do not move to a new minor version without explicit user approval. - Do not move to a new minor or stable version without explicit user approval.
- Keep `VERSION`, root `pyproject.toml`, package versions, package - Keep `VERSION`, root `pyproject.toml`, package versions, package
`__version__`, `README.md`, and `CHANGELOG.md` synchronized. `__version__`, `README.md`, and `CHANGELOG.md` synchronized.
+720 -14
View File
@@ -1,5 +1,148 @@
# Changelog # Changelog
## beta 1.0.0.1016 - 2026-10-16
- Promote JWeBot from alpha to beta after the local, staging, privacy, and
production deployment gates stabilized.
- Consolidate the current reviewed local command set, including virtual coin
transfer, loans, repayment, group leaderboard, multiplayer lottery,
blackjack, Steam queries, and CS Major autochess.
- Externalize CS Major autochess game content into package JSON data files for
cards, coaches, narratives, draft/shop/economy rules, match formats, and
MR15/overtime settings.
- Add a maintained command catalog and keep debug session notes out of the
repository by default.
- Keep release validation on `tools/sync-local-extensions.py --check`,
`tools/check-local.sh`, and `tools/check-secrets.py --history`.
## alpha 0.6.11.1015
- Add `/大富翁榜`, `/金币排行`, `/金币排行榜`, and `/richlist` for a
group-scoped virtual coin leaderboard. Rankings are read-only, sorted by
current coin balance, recover display names from configured aliases and
observed profiles, and mark active loan principal when present.
- Split leaderboard command ownership into a dedicated reviewed
`coin-ranking` local extension.
## alpha 0.6.10.1015
- Add virtual coin loans with `/贷款 [金额]` and `/loan amount`, plus
repayments through `/还款 [金额|全部]` and `/repay`. Loans are stored in
the same group-scoped virtual coin account, capped at 500 coins, limited to
one active loan per account, and replay-safe through the existing ledger
transaction path.
- Split loan command ownership into a dedicated reviewed `coin-loan` local
extension so the single-player game plugin stays under the command-count
validation limit.
## alpha 0.6.9.1015
- Move CS Major autochess public game data out of `cs_autochess.py` and into
package data files under `jwebot_official_extensions/data/cs_autochess/`.
Player cards, coach cards, narrative fragments, shop/economy odds, draft
pacing, synergy thresholds, match formats, MR15/overtime settings, and
special event rewards are now data driven.
- Add startup validation for the externalized data so missing fields,
duplicate card IDs, invalid schema versions, or an undersized narrative
library fail fast before the command engine runs.
- Add regression coverage that verifies the JSON files are packaged, readable,
and consistent with the runtime card, coach, match-format, and narrative
inventories.
- Add virtual coin transfers with `/转账 对方 金额` and `/transfer name amount`.
Transfers are single-transaction ledger updates, require the receiver to
initialize `/金币` first, reject self-transfers, and replay safely by event ID.
## alpha 0.6.8.1015
- Add an atomic compatibility path for old active CS autochess runs. When a
group still has a pre-schema-3 active tournament, `/自走棋开始` now archives
that old run as cancelled and opens a fresh schema-3 season in the same
SQLite transaction.
- Preserve old story and battle archive rows while ensuring the new season is
the only active autochess run selected by future commands.
## alpha 0.6.7.1015
- Expand CS Major autochess content to 100+ player cards and an 18-coach
metadata pool while keeping the opening coach draft to 10 visible choices.
- Replace abstract training targets with real round-robin training rounds.
Each round resolves participant-vs-participant or participant-vs-AI BO1
scrims, then opens an operation window for D rolling, locking, lineup swaps,
tactics, and readiness.
- Replace fixed score bands with deterministic MR15 round simulation. Map
scores now come from individual round outcomes, including 15-15 overtime,
instead of direct mappings such as 16-6 or 16-9.
- Add a 120+ line local narrative library for openings, streaks, pauses,
clutches, economy beats, closes, training hooks, and media/sponsor
challenges so match reports vary across maps.
- Hide raw target/performance calculation text from training, match, and
challenge reports while preserving structured battle facts in the archive.
- Start drafted players as two-star素材 and rebalance roster completion toward
low/mid-cost cards, leaving upgrades and synergies to the shop loop.
## alpha 0.6.6.1015
- Rebuild CS Major autochess around multi-message hosting: tournament
welcomes, seed order, draft boards, status panels, training results, match
reports, standings, brackets, and awards are emitted as separate bounded
replies.
- Replace instant full rosters with three draft rounds: round one selects from
a fixed 10-coach pool, rounds two and three draft players, then each team is
completed to five starters, one coach, and three bench slots.
- Run the full event as training camp, BO3 single-round-robin group stage,
one media/sponsor challenge after group round one or two, BO5 semifinals,
BO5 final, and final awards.
- Let readiness advance phases automatically. Group matches wait for all real
players, while knockout matches only wait for real players still in the
current pairing, so eliminated players are not prompted to prepare.
- Add local multi-text reply support across the SDK, official extension
adapter, Edge response contract, and recorder GUI sender while preserving
single-message compatibility.
## alpha 0.6.5.1015
- Extend the AI test path for CS Major autochess to support two-human games:
`/自走棋加AI战队` now adds AI 战队 A and AI 战队 B when exactly two
real players have registered.
- Keep the same command for two- and three-human test modes. The tournament
engine selects 2 人+2 AI or 3 人+1 AI from the current registration count.
- Lock the roster after AI teams join, seed the four-team bracket as 1v4 and
2v3, and keep AI teams out of the human preparation order while preserving
independent AI operations and story archives.
## alpha 0.6.4.1015
- Add a reviewed 2-8 player CS Major autochess extension with a fixed
five-player starting lineup, one always-active coach, an eight-slot bench,
and a six-candidate shop that shows complete game data.
- Ship a fixed 60-player local pool with rifle, sniper, and in-game-leader
roles; team and nation synergies; shared copy counts; and deterministic
three-copy/two-star and nine-copy/three-star upgrades.
- Keep all five player slots occupied. Lineup changes are one-for-one swaps,
starters cannot be sold directly, and coach upgrades only add a 2%-10%
team bonus.
- Add player-selected firepower, stability, and clutch tactics. Resolve
stability over firepower, firepower over clutch, clutch over stability,
plus sniper/rifle/leader role counters before bounded +/-2% map variance.
- Generate the competition format once at tournament start: two players go
directly to the final, three play a round robin for two final places, four
use seeded 1v4 and 2v3 semifinals, and five to eight play a complete round
robin for four semifinal places.
- Add an explicit three-human-plus-one-AI test mode. The host can add one
deterministic local AI as seed four; it buys players, upgrades its coach,
swaps its strongest lineup, and selects a counter tactic without an online
model.
- Give the bot a persistent host state machine: welcome and format briefing,
rotating human preparation order, turn enforcement, explicit handoffs,
round introductions, matchup calls, overtime narration, advancement, and
championship presentation.
- Run group matches as BO3 and knockout matches as BO5. Simulate every map
under MR15, including 15-15 overtime, 19-17/22-20 scores, and separately
archived overtime stories.
- Persist run state, idempotent action responses, structured battles, and
every generated story in serialized SQLite transactions. Invalid archives
roll back the full action.
## alpha 0.6.3.1015 ## alpha 0.6.3.1015
- Replace instant personal lottery tickets with one persistent multiplayer - Replace instant personal lottery tickets with one persistent multiplayer
@@ -16,7 +159,7 @@
- Add per-game personal sequence numbers and `/游戏记录` (`/gamehistory`) for - Add per-game personal sequence numbers and `/游戏记录` (`/gamehistory`) for
prize draws, lottery rounds, and blackjack hands. prize draws, lottery rounds, and blackjack hands.
- Prefix game output with the configured player label and game number, such as - Prefix game output with the configured player label and game number, such as
`Jacky #1:...`; aliases share the same counter and account. `Player #1:...`; aliases share the same counter and account.
## alpha 0.6.2.1015 ## alpha 0.6.2.1015
@@ -34,7 +177,7 @@
stand in a separate command. Combined forms return this workflow without stand in a separate command. Combined forms return this workflow without
opening a game or deducting funds. opening a game or deducting funds.
- Prefix blackjack hands, status and settlement replies with the current - Prefix blackjack hands, status and settlement replies with the current
group-scoped player's configured display label, such as `jacky:...`. group-scoped player's configured display label, such as `player:...`.
## alpha 0.6.1.1015 ## alpha 0.6.1.1015
@@ -85,18 +228,581 @@
backup; pre-public operational history remains only in protected local backup; pre-public operational history remains only in protected local
archives. archives.
## Pre-Public Milestones ## alpha 0.5.11.1015
Earlier alpha work established: - Reduce the conservative follow-latest scroll burst from a fixed `25ms` to
`1ms` between each of the existing 30 wheel events.
- Reuse the XDamage producer's verified WeChat window identifier in the
adaptive ready-layout gate instead of rediscovering the same window before
each screenshot.
- Preserve the shared GUI lock, ready-window geometry gate, second screenshot,
and loaded-bottom visual confirmation.
- Add privacy-safe per-step capture telemetry and regression coverage for the
optimized slow path.
- adaptive OCR and XDamage capture; ## alpha 0.5.10.1015
- guarded GUI send with visual confirmation;
- durable ACK/final delivery ordering;
- privacy-safe latency telemetry;
- capability-limited SDK contracts;
- signed extension artifacts and isolated Host execution;
- reviewed command ownership and generated adapters;
- coordinated production backup, health gates, and rollback.
Private host, person, address, account, incident, and deployment details are - Prioritize queued fast acknowledgements and local-command replies over queued
intentionally omitted from the public changelog. final replies in the guarded GUI sender.
- Keep the active GUI send non-preemptive and preserve strict FIFO ordering
within each priority class through a monotonic sequence number.
- Raise the adaptive recorder CPU quota from `150%` to `250%` to reduce
screenshot subprocess stalls under OCR and GUI contention.
- Add deterministic coverage for ACK/local priority, final FIFO ordering,
single-sender serialization, and clean worker shutdown.
## alpha 0.5.9.1014
- Preserve both capture call contracts in the adaptive ready-window wrapper:
zero arguments for startup/polling and one verified window identifier for the
optimized XDamage producer.
- Fix the alpha 0.5.8 internal `CaptureProducerError` restart loop that began on
the first relevant XDamage event.
- Add direct regression tests for both ready-gated capture signatures.
## alpha 0.5.8.1014
- Recover one unseen bottom-most bot trigger when a viewport resync has no
visual overlap, instead of silently dropping the new command.
- Persist a bounded recent-trigger visual history so an old command is not
replayed by a later no-overlap resync; ordinary messages remain fail-closed.
- Seed that history from one startup baseline before enabling recovery, so a
service restart cannot replay a visible pre-existing command.
- Reuse the XDamage monitor's verified WeChat window identifier and the
maximize gate's geometry result, removing one redundant window lookup and
one redundant geometry subprocess from each changed-frame capture.
- Add regression coverage for unseen-trigger recovery, seen-trigger replay
suppression, signature migration, and the optimized capture path.
## alpha 0.5.7.1014
- Replace the single direct-request worker with two parallel generation workers
and one strictly ordered GUI delivery worker for ordinary chat and Steam
requests.
- Keep final replies in durable `created_at, event_id` order even when a newer
provider call completes first; failed acknowledgements and exhausted requests
remain terminal and do not block later replies.
- Separate generation and delivery retry counters so each phase retains its
complete retry budget.
- Wake generation and delivery workers immediately on enqueue, generation
completion, and acknowledgement instead of adding a one-second idle polling
delay to the normal path.
- Split direct-queue telemetry into `direct_generation` and `direct_delivery`
phases and add concurrency, ordering, ACK-race, retry, Steam, and duplicate
regression coverage.
## alpha 0.5.6.1014
- Preserve both pre-draft and post-draft group fingerprint gates while
capturing only the configured title region instead of decoding the complete
WeChat window twice per GUI send.
- Keep the previous full-window fingerprint path as a compatibility fallback
for older adaptive upstream modules.
- Add GUI sender coverage for the title-region primary path, full-window
fallback, and visual group-mismatch fail-closed handling.
## alpha 0.5.5.1014
- Accept incoming WeChat bubbles up to `900px` wide so long single-line bot
mentions are not discarded before OCR; preserve position, height, area, and
outgoing-bubble exclusion gates.
- Wait for adaptive OCR warmup and a first baseline capture from the current
service start before reporting `adaptive ready` or successful deployment.
- Fail and roll back deployment if the adaptive service exits or cannot
establish its recorder baseline within 90 seconds.
- Retry the WeChat ready-window gate for up to 30 seconds and discover the
exact `Weixin` window by both class and title before failing closed.
- Pin production privilege switching and X11 inspection to verified absolute
executable paths so a restricted root `PATH` cannot masquerade as a missing
WeChat window.
## alpha 0.5.4.1014
- Stabilize visible-message sequence alignment with thresholded binary
sender/body fingerprints while retaining exact RGB keys for OCR cache
invalidation.
- Reset 0.5.3 exact-visual recorder state to a safe no-reply baseline during
the signature migration.
- Prefix coordinated deploy phases with `[JWeBot]`, report explicit
`starting`, `ready`, and `active` states, and run user installation from the
target home to avoid inherited `/root` working-directory warnings.
## alpha 0.5.3.1014
- Align visible message sequences with existing exact bubble visual
fingerprints instead of OCR text, preventing scroll/resync OCR drift from
dropping a newly mentioned slash command.
- Reset legacy text-signature recorder state to a no-reply visual baseline on
startup, avoiding duplicate replay during the signature-scheme migration.
- Preserve repeated identical commands as separate visual occurrences.
## alpha 0.5.2.1014
- Print an ASCII pixel-art project signature only after the coordinated
production deployer passes every cutover gate and disables rollback.
## alpha 0.5.1.1014
- Exclude the static bottom-left input hint from the realtime text ROI so an
empty WeChat input box is not misclassified as dirty.
- Label the corrected ROI as `input_text_core_v2` in bridge telemetry, keeping
pre-fix and post-fix production evidence distinguishable.
## alpha 0.5.0.1014
- Move visible WeChat delivery onto a persistent in-process GUI sender queue
hosted by the adaptive recorder, avoiding per-message bridge subprocesses for
ACK, local-command replies, and final deliveries.
- Add a private AF_UNIX sender socket so Edge delivery helpers submit final
replies to the same serialized GUI queue instead of racing the input box.
- Combine clear, paste, ROI draft flag, post-draft group check, Enter send,
ROI clear confirmation, and send-button fallback into one bridge call.
- Confirm the input ROI is empty after Ctrl-A/Backspace before using it as the
paste baseline, preventing repeated `喵......` ACK drafts from being mistaken
for paste failures.
## alpha 0.4.15.1014
- Force adaptive recorder logging to INFO so GUI bridge timing telemetry is
visible on ACK and final delivery paths.
- Allow ROI, poll, pixel-threshold, flag, and coordinate fields through the
packaged runtime telemetry sanitizer.
## alpha 0.4.14.1014
- Replace full-input send confirmation with a left-side input-text ROI flag so
draft and send confirmation poll only the region that should visibly change.
- Restore the guarded send order to Enter first, then click the scaled send
button only as a fallback when ROI clearing is not visually confirmed.
- Emit ROI coordinates, flag state, flag wait time, confirmation source, and
bridge stderr tail diagnostics for GUI delivery failures.
## alpha 0.4.13.1014
- Emit detailed guarded GUI delivery timings for lock acquisition, pre/post
draft screenshots, group fingerprint checks, bridge subprocess duration,
draft paste confirmation polling, send-button confirmation polling, Enter
fallback, callback hooks, and total send time.
- Return per-step timing metadata from the adaptive GUI bridge so ACK and final
delivery reports can attribute fixed waits, screenshot confirmation, VNC
input, and fallback paths without logging message text.
## alpha 0.4.12.1014
- Optimize guarded WeChat GUI delivery by clicking the scaled send button first
and polling for visual confirmation instead of waiting on fixed sleeps.
- Preserve the Enter-key fallback when the button path is not visually
confirmed.
## alpha 0.4.11.1014
- Add explicit `edge.fast_ack.*` and `recorder.fast_ack.*` telemetry names
alongside the existing ACK metrics, making fast acknowledgement latency
independently filterable without breaking historical dashboards.
## alpha 0.4.10.1014
- Cache configured person-alias rows once per group and make configured
identity persistence idempotent, eliminating thousands of redundant
`FULL`-synchronous SQLite commits during Edge startup.
- Reduce measured production host systemd socket readiness on the production database
snapshot from more than 90 seconds to about one second.
## alpha 0.4.9.1014
- Wait up to 45 seconds for the newly started Edge socket and health response
before deciding that a production cutover failed, while still failing
immediately if systemd marks the service failed.
## alpha 0.4.8.1014
- Keep the adaptive OCR process alive while WeChat is absent or awaiting login,
then resume capture against the ready main window without a systemd restart
storm.
- Install the reviewed `wechat_local_ocr.py` beside the adaptive runtime instead
of importing an independently managed root copy.
- Add a root-coordinated production deploy that records both systemd
topologies, backs up root and user files plus SQLite, disables both legacy OCR
units, and rolls back all layers when a health gate fails.
- Require empty OCR queues, a ready WeChat window, Edge socket health, stable
adaptive restart counts, and SQLite integrity before accepting a production
cutover.
- Preserve incoming service state in legacy install and upgrade tools; legacy
OCR now remains disabled unless an operator explicitly opts in.
- Prevent Extension Host imports from writing bytecode into verified releases,
and exclude only standard interpreter cache files from source packaging.
- Add deployment contract tests and shell-aware syntax validation to the local
release gate.
## alpha 0.4.7.1014
- Prewarm RapidOCR recognition and detection paths before live capture.
- Cache successful bubble recognition by bounded exact visual fingerprint,
recognize only new or changed crops, and periodically revalidate the cache.
- Persist OCR command events through atomic file replacement plus file and
directory `fsync`, then immediately wake the in-process responder while
retaining the one-second recovery scan.
- Name the three execution paths explicitly as `local-command`, `deepseek`, and
`openclaw` in Edge responses and privacy-safe routing telemetry.
- Add signed plugin description/help metadata, validate it through Host health,
generate bundled `/功能列表` output automatically, and update plugin creation
tooling and author documentation.
## alpha 0.4.6.1014
- Add a repeatable local performance baseline for OCR preprocessing, sequence
alignment, durable ACK/queue state, and real signed Host process execution.
- Add a structured telemetry-log analyzer with p50, p95, maximum, status, and
event-count summaries.
- Measure queue waiting, capture-buffer age, ACK end-to-end latency, and final
Edge delivery latency without recording message content.
- Establish the bottleneck order from local measurements and existing runtime
evidence: RapidOCR, provider generation, polling wakeups, then one-shot Host
startup; SQLite and sequence work remain below 2 ms p95 locally.
- Document the evidence, measurement gaps, and optimization order for later
`0.4.x` iterations.
## alpha 0.4.5.1014
- Close the approved repository-only Extension Host migration scope.
- Treat an invalid installed signed release as a startup failure rather than a
preferred-mode fallback; only missing or startup-unhealthy Hosts may fall
back.
- Enforce the end-to-end Host deadline after Kernel service execution and
expand transport-error telemetry.
- Document completed local work, explicitly deferred external operations, and
the next measurement-led OCR/queue/ACK performance-analysis phase.
## alpha 0.4.4.1014
- Add privacy-safe structured telemetry for OCR, routing, ACK preparation and
confirmation, queues, providers, Host processes, service brokering, and final
GUI delivery.
- Hash event and group identifiers, reject message-body fields, and retain only
low-cardinality process-local counts and latency summaries.
- Expose telemetry summaries through Edge health without making telemetry part
of delivery correctness.
- Instrument OCR capture/recognition/queue stages and distinguish ACK GUI
delivery from Edge confirmation.
- Document ACK ordering and the measurement points needed for future OCR,
queue, Host-startup, and provider latency optimization.
## alpha 0.4.3.1014
- Require a signed release to pass isolated Host health before atomic
activation; failed new releases are removed without switching `current`.
- Add explicit local-only health bypass for packaging environments.
- Complete real-process parity coverage for all 12 reviewed plugins, including
memory CRUD, both stateful games, dice challenge, rankings, history, and
Steam fast-ACK final generation.
- Fix Host protocol writes so broker messages bypass redirected plugin stdout
without exposing plugin output to the parent protocol.
## alpha 0.4.2.1014
- Load installed signed plugin manifests into a deterministic startup catalog
and freeze command ownership before routing.
- Add `in-process`, `prefer-host`, and `host-only` Edge execution modes with
subprocess or AF_UNIX Host transports.
- Route signed healthy plugins through the isolated Host while preserving
Kernel-direct commands and Steam fast-ACK queueing.
- Permit startup fallback for missing or unhealthy preferred Hosts, but never
replay an active invocation after a possible stateful service call.
- Add real subprocess end-to-end coverage from Edge through the generated
adapter, Host protocol, Kernel service broker, and final reply.
## alpha 0.4.1.1014
- Add bounded bidirectional JSON-line service brokering between isolated
Extension Host processes and Kernel-owned service profiles.
- Enforce declared profile ownership, call IDs, call count, message size, and
end-to-end process/socket deadlines.
- Add Host health inventory responses and broker support to both subprocess and
AF_UNIX clients.
- Keep service errors generic across the isolation boundary.
## alpha 0.4.0.1014
- Start the approved `0.4.x` local extension-host migration and performance
instrumentation milestone.
- Keep signing-pipeline integration, key rotation, PR rehearsal, target-machine
provisioning, deployment, and Linux verification deferred.
## alpha 0.3.1.1014
- Add OpenSSH `sshsig` publisher signatures for canonical extension release
records.
- Bind the publisher principal, release filename, artifact metadata, source
digest, manifest author, and exact file list through one signed payload.
- Require trusted publisher verification for activation, Host loading, and
rollback; unsigned paths now require an explicit local-test override.
- Persist the canonical signature record with each installed release and
re-verify it before execution or rollback.
- Add signing support to package, verify, activate, rollback, and standalone
release-signing tools.
- Keep production host provisioning, deployment, and systemd validation deferred.
## alpha 0.3.0.1014
- Close the `0.2.x` complete-function migration after the corrected granular
plugin ownership model passed the full local gate.
- Start the approved `0.3.x` baseline with Kernel-direct operational commands,
official-owned person memory, and 11 project-owned plugins.
- Keep Linux deployment and systemd verification explicitly deferred.
## alpha 0.2.6.1014
- Replace grouped plugin domains with one plugin per independently
understandable function.
- Keep dice and dice challenge as one complete project-authored collection.
- Make person-memory the only official-authored command plugin.
- Move feature list, status, and balance to unconditional Kernel-direct
routing.
- Add required manifest author metadata and release-record schema version 2.
- Generate 11 project plugin packages under the reviewed local-extension namespace and keep only
person-memory under `extensions/official`.
- Rename the canonical rollback switch to
`JWEBOT_LOCAL_EXTENSIONS_ENABLED`, while accepting the old environment key
as a compatibility fallback.
## alpha 0.2.5.1014
- Move the complete Steam provider into `jwebot-official-extensions`.
- Migrate fixed roster, group aliases, mode filtering, single-person lookup,
cache, API batching, proxy enforcement, and error classification.
- Preserve the existing public `SteamClient`, `SteamUnavailable`, API base, and
Mihomo default exports through the packaged Edge runtime.
- Keep the no-direct-network-fallback regression tests passing.
## alpha 0.2.4.1014
- Add full official-adapter versus compatibility-path parity tests.
- Cover every stateless command family, all memory operations, every
dice-challenge phase, guess-number lifecycle, RPS lifecycle, status, balance,
Steam generation, and restart persistence.
- Make parity and persistence checks part of the permanent local/CI gate.
## alpha 0.2.3.1014
- Route all known local commands through generated official SDK adapters by
default.
- Execute stateful behavior through named in-process service brokers without
exposing raw Ledger or providers to adapters.
- Preserve the Steam fast-ACK asynchronous path through the official adapter.
- Add `JWEBOT_OFFICIAL_EXTENSIONS_ENABLED=0` as a compatibility rollback to
direct command-engine dispatch.
- Keep unknown slash-command errors on the compatibility path.
- Install generated adapter modules with the official package.
## alpha 0.2.2.1014
- Add reviewed `service_profiles` as a narrow SDK/manifest capability.
- Generate real Host-loadable packages for all seven official function domains.
- Register every existing alias through SDK command adapters.
- Delegate complex stateful behavior through named Kernel service profiles
instead of exposing raw Ledger or provider objects.
- Add synchronization checks so official manifests, adapters, docs, and command
ownership cannot drift.
- Validate, source-scan, and package every official extension in CI.
## alpha 0.2.1.1014
- Move the complete local command engine out of the Edge runtime into the
installable `jwebot-official-extensions` package.
- Preserve all command replies, persistence, deterministic seeds, game state
machines, memory operations, rankings, status, balance, and Steam behavior.
- Inject Ledger, Steam, balance, reply-cleaning, identity, and memory-policy
brokers from Kernel instead of importing Edge internals.
- Add a unique ownership map covering every current command kind and alias.
- Add an authoritative migration matrix documenting every command subfunction
and non-command Kernel responsibility.
## alpha 0.2.0.1014
- Start the approved `0.2` full-function migration milestone.
- Move the complete Edge runtime into `jwebot-kernel`.
- Keep `jwebot_edge.py` as a compatibility CLI and module alias so existing
imports and runtime monkeypatches continue to affect implementation globals.
- Update tests and the installer for the packaged runtime.
- Preserve all existing command, queue, ledger, model, memory, broadcast, and
delivery behavior.
- Do not deploy or validate on Linux in this stage.
## alpha 0.1.11.1014
- Add a bounded AF_UNIX client matching the systemd socket-activated Host
design.
- Send one canonical JSON request, half-close the write side, and read one
bounded response.
- Fail closed on socket timeout, unavailable socket, malformed response, and
Host error status.
- Keep the socket client disconnected from the current production Edge path.
## alpha 0.1.10.1014
- Add uninstalled systemd socket/service templates for the extension Host.
- Specify a fixed unprivileged identity, AF_UNIX-only private network,
read-only system/release paths, empty capabilities, syscall filtering, and
CPU/memory/process/runtime limits.
- Add static sandbox-policy checks and unit tests.
- Require explicit target-host provisioning and systemd verification before
any future enablement.
## alpha 0.1.9.1014
- Add the Kernel-side extension Host client with a minimal inherited
environment and isolated process group.
- Enforce parent wall-time deadlines and kill the entire Host process group on
timeout.
- Bound request, response, and stderr sizes and reject malformed protocol
responses.
- Limit plugin stdout/stderr inside the Host.
- Hash both plugin log messages and field-name sets instead of emitting plugin
text.
- Test timeout child-process cleanup and environment-secret exclusion.
- Keep the client disconnected from the production Edge path.
## alpha 0.1.8.1014
- Add an installable `jwebot-extension-host` package.
- Re-verify installed release content, manifest, and source policy before import.
- Enforce exact declared command registration and freeze it before invocation.
- Add a bounded single-request JSON stdin/stdout protocol.
- Scrub inherited environment values and redirect plugin stdout away from IPC.
- Keep storage, HTTP, and scheduling brokers fail-closed.
- Return generic plugin failure classes without exception text.
- Do not connect the Host to Edge or install it on production host.
## alpha 0.1.7.1014
- Add verified release installation under full source-digest directories.
- Add locked, atomic `current` and `previous` activation pointers.
- Re-verify installed release file lists, records, and source content before
reuse.
- Add explicit local activation and rollback tools with no default production
path.
- Test rollback, tampered artifacts, tampered installed content, unsafe
pointers, and private installed-file permissions.
- Do not execute extension code or modify production host.
## alpha 0.1.6.1014
- Add deterministic, content-addressed extension release artifacts.
- Add canonical source and artifact SHA-256 records.
- Verify artifacts without extraction and reject path traversal, non-regular
members, expansion limits, file-list drift, and content tampering.
- Refuse replacement of an existing digest-addressed artifact with different
bytes.
- Build and verify the example extension artifact in Gitea Actions.
- Explicitly defer publisher signing and production host activation to a later phase.
## alpha 0.1.5.1014
- Add a static extension source-policy preflight before packaging.
- Reject unapproved imports, file/process/socket access, dynamic execution
builtins, and dunder attribute traversal.
- Enforce source count, size, UTF-8, syntax, and symlink limits.
- Make `validate-plugin.py` validate both manifest and source policy.
- Document that static policy is a CI prefilter, not a replacement for OS
process isolation.
## alpha 0.1.4.1014
- Restrict extension command names to Unicode word characters, dots, and
hyphens.
- Reject quote/newline template injection before plugin skeleton files are
created.
- Add manifest and generator regression tests while preserving Chinese command
names.
## alpha 0.1.3.1014
- Add a safe command-extension generator that refuses invalid IDs, invalid
commands, path traversal, and overwriting existing extensions.
- Add official and friend extension namespaces.
- Add an author guide, SDK API reference, and reusable AI-author prompt.
- Include author tooling and required documentation in the repository gate.
## alpha 0.1.2.1014
- Add a deterministic extension catalog that accepts reviewed manifests only
during startup.
- Reject duplicate plugin IDs and case-insensitive command ownership conflicts.
- Freeze registration before runtime command resolution.
- Keep catalog views immutable and require explicit command-token boundaries.
## alpha 0.1.1.1014
- Make `jwebot-kernel` the authoritative pure routing implementation used by
the existing Edge compatibility entrypoint.
- Preserve the existing `jwebot_edge.py` public function shapes for current
callers and tests.
- Update the production host installer to deploy and roll back the SDK and Kernel package
directories together with the compatibility scripts.
- Do not deploy or restart any production service in this repository change.
## alpha 0.1.0.1014
- Start the approved `0.1` milestone with installable `jwebot-sdk` and
`jwebot-kernel` packages.
- Add versioned event, reply, fact, and extension-result contracts.
- Add capability-limited context protocols that expose no GUI, group selector,
raw ledger, environment, shell, or unrestricted network access.
- Add strict plugin manifest validation and pure local/chat/cloud routing
primitives.
- Add a minimal `/hello` command extension and package boundary tests.
- Keep all existing root runtime entrypoints unchanged for production host compatibility.
## alpha 0.0.7.1014
- Move unit tests into `tests/`.
- Update tests to load root compatibility entrypoints from the repository root.
- Update local verification commands to discover tests from `tests/`.
## alpha 0.0.6.1014
- Add repository-safe configuration templates under `config/examples/`.
- Add `docs/configuration-guide.md` for runtime configuration ownership and
deployment destinations.
- Add `tools/check-config.sh` and include it in the local migration gate.
- Keep real secrets and production host runtime files outside Git.
## alpha 0.0.5.1014
- Add a Gitea Actions workflow that runs the standard local migration gate on
push and pull request.
- Add `requirements-dev.txt` for local and CI test dependencies.
- Document dependency installation in `README.md`.
## alpha 0.0.4.1014
- Move home-backup operational material into `ops/home-backup/`.
- Move the TigerVNC config snippet into `deploy/config/`.
- Keep backup material in the repository for reference while separating it from
the bot runtime path.
## alpha 0.0.3.1014
- Add `tools/check-local.sh` as the standard local migration gate.
- Wire the gate to compile Python entrypoints, validate shell scripts, reject
active emergency debug hooks outside their archive docs, and run the full
unittest suite.
- Document the local check command in `README.md`.
## alpha 0.0.2.1014
- Remove temporary emergency HTTP debug hooks from production Python entrypoints.
- Preserve removed hook context in `docs/debug/emergency-hooks-archive.md`.
- Add `docs/configuration-inventory.md` to document hardcoded addresses, paths,
service names, dimensions, models, and their migration policy.
- Keep historical incident markdown unchanged for auditability.
## alpha 0.0.1.1014
- Establish the repository version baseline for the private alpha migration.
- Move operational docs into `docs/`.
- Move deployment scripts, defaults, and systemd units into `deploy/`.
- Move the cloud responder prompt workspace into `agents/wechat-responder/`.
- Move the old direct OpenClaw watcher into `legacy/`.
- Keep current Python runtime entrypoints at repository root to preserve test and
deployment compatibility during the first migration step.
+31 -28
View File
@@ -8,18 +8,15 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 GUI 发送链路回复。
当前版本:`alpha 0.6.3.1015`。 当前版本:`beta 1.0.0.1016`,更新日期:2026-10-16。
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加 1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见 Steam 状态与时长查询、虚拟金币游戏、多人彩票、21 点、CS Major
[0.6.1 插件说明](docs/local-plugins-0.6.1.md)。 自走棋、生产部署与回滚门禁。CS Major 自走棋的选手、教练、叙事和规则
0.6.2 为 DeepSeek 流式回复增加 30 秒总时限,避免未结束的上游连接长期 已经外置为包内 JSON,虚拟金币支持转账、贷款、还款和群内排行榜。
占满生成 worker;21 点开局金额明确作为本局下注,并兼容 OCR 丢失空格。
21 点牌面和结算以当前玩家展示名开头。快速 ACK、重试和严格顺序投递 完整命令见[命令目录](docs/command-catalog.md)。历史版本记录见
规则保持不变。 [CHANGELOG](CHANGELOG.md)。
0.6.3 将彩票改为群内多人轮次,加入报名、状态、主持人开奖和取消;
抽奖、彩票、21 点统一使用 `Jacky #N:...` 局号并支持 `/游戏记录`。
完整玩法见[多人彩票与游戏记录](docs/group-lottery-0.6.3.md)。
### 运行流程 ### 运行流程
@@ -86,10 +83,17 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/上下文 [100|200|300](/context) /上下文 [100|200|300](/context)
/人物列表 [页码](/people);/人物 [名称或别名](/person) /人物列表 [页码](/people);/人物 [名称或别名](/person)
/金币(/wallet);/抽奖 [金额](/draw) /金币(/wallet);/抽奖 [金额](/draw)
/大富翁榜(/金币排行)
/转账 对方 金额(/transfer name amount)
/贷款 [金额](/loan amount);/还款 [金额|全部](/repay)
/彩票 开始|报名|当前状态|开奖|取消(/lottery) /彩票 开始|报名|当前状态|开奖|取消(/lottery)
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 /21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
/游戏记录 [页码](/gamehistory) /游戏记录 [页码](/gamehistory)
/游戏帮助(/games) /游戏帮助(/games)
/自走棋开始|报名|开赛|状态|档案
/自走棋加AI战队(两名或三名真人测试模式)
/选1~6(教练轮为 /选1~10);/刷新;/锁定
/战术 火力|稳定|残局;/阵容;/上阵 备战编号 [首发位];/出售 备战编号;/结束回合
/丢骰子 [NdM] /丢骰子 [NdM]
/猜数字 开始 [上限] /猜数字 开始 [上限]
/石头剪刀布 [开始|加入|出|状态|结算|结束] /石头剪刀布 [开始|加入|出|状态|结算|结束]
@@ -258,9 +262,17 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```text
alpha 0.6.3.1015 beta 1.0.0.1016
``` ```
Updated: 2026-10-16.
The 1.0 Beta baseline consolidates the stable local command set, person and
context indexes, Steam presence/playtime queries, virtual coin games,
multiplayer lottery, blackjack, CS Major autochess, and production deployment
gates. CS Major autochess game content is now packaged as JSON data, and
virtual coins support transfer, loans, repayment, and group leaderboards.
## Runtime Flow ## Runtime Flow
```text ```text
@@ -326,7 +338,8 @@ migration, debug archives, and root deployment settings.
## Commands ## Commands
The built-in command inventory is generated from reviewed plugin descriptors. The built-in command inventory is generated from reviewed plugin descriptors.
Common commands include: See the full [command catalog](docs/command-catalog.md). Common commands
include:
```text ```text
/功能列表 /功能列表
@@ -355,22 +368,12 @@ the final separator shown below:
/steam时长Dota2人=玩家名 /steam时长Dota2人=玩家名
``` ```
Version 0.6.1 adds local `/context 100|200|300` extractive summaries, Chinese aliases share the same implementations as their English counterparts.
`/people` and `/person` lookup, and `/wallet`, `/draw`, `/lottery`, `/blackjack` Virtual coin funds, game state, transfers, loans, repayments, and replay
single-player virtual coin games. Chinese aliases share the same implementations. responses commit through the same ledger transaction path. Native WeChat
Accounts receive 1000 virtual coins once; funds, game state and event replay account IDs are unavailable, so accounts are scoped by OCR display identity and
results commit together. OCR display aliases identify group-scoped people; configured aliases. See
native WeChat account IDs are unavailable. See
[plugin rules and capture reconciliation](docs/local-plugins-0.6.1.md). [plugin rules and capture reconciliation](docs/local-plugins-0.6.1.md).
Version 0.6.2 adds a 30-second total deadline to direct DeepSeek streams so an
unfinished upstream response cannot occupy both generation workers for about
15 minutes. Blackjack start amounts are the current round's stake; compact OCR
forms are accepted, while hit/stand remain explicit follow-up commands.
Blackjack hand and settlement replies start with the current player's configured
display label. ACKs, retries, and ordered final delivery remain unchanged.
Version 0.6.3 replaces instant lottery tickets with persistent multiplayer
group rounds, adds indexed personal game history, and raises blackjack stakes
to 1-500 coins. See [multiplayer lottery and game records](docs/group-lottery-0.6.3.md).
Steam prefers private per-person SteamID mappings from the active profile and Steam prefers private per-person SteamID mappings from the active profile and
uses the corresponding group display IDs and aliases. Without a private roster, uses the corresponding group display IDs and aliases. Without a private roster,
+1 -1
View File
@@ -1 +1 @@
alpha 0.6.3.1015 beta 1.0.0.1016
+17 -1
View File
@@ -9,6 +9,7 @@ sdk_source_dir="$source_dir/packages/jwebot-sdk/src/jwebot_sdk"
kernel_source_dir="$source_dir/packages/jwebot-kernel/src/jwebot_kernel" kernel_source_dir="$source_dir/packages/jwebot-kernel/src/jwebot_kernel"
official_source_dir="$source_dir/packages/jwebot-official-extensions/src/jwebot_official_extensions" official_source_dir="$source_dir/packages/jwebot-official-extensions/src/jwebot_official_extensions"
official_adapter_source_dir="$official_source_dir/adapters" official_adapter_source_dir="$official_source_dir/adapters"
official_autochess_data_source_dir="$official_source_dir/data/cs_autochess"
target_dir="$HOME/.local/lib/wechat-health-watch" target_dir="$HOME/.local/lib/wechat-health-watch"
unit_dir="$HOME/.config/systemd/user" unit_dir="$HOME/.config/systemd/user"
config_dir="$HOME/.config/jwebot" config_dir="$HOME/.config/jwebot"
@@ -237,6 +238,8 @@ trap rollback EXIT HUP INT TERM
jwebot_log "user runtime preflight starting" jwebot_log "user runtime preflight starting"
test -x "$runtime_python" test -x "$runtime_python"
for path in \ for path in \
"$source_dir/VERSION" \
"$source_dir/CHANGELOG.md" \
"$runtime_source_dir/$ocr_script_name" \ "$runtime_source_dir/$ocr_script_name" \
"$runtime_source_dir/$script_name" \ "$runtime_source_dir/$script_name" \
"$runtime_source_dir/$adaptive_script_name" \ "$runtime_source_dir/$adaptive_script_name" \
@@ -262,6 +265,9 @@ do
test -d "$directory" test -d "$directory"
find "$directory" -maxdepth 1 -type f -name '*.py' | grep -q . find "$directory" -maxdepth 1 -type f -name '*.py' | grep -q .
done done
test -d "$official_autochess_data_source_dir"
find "$official_autochess_data_source_dir" -maxdepth 1 -type f -name '*.json' \
| grep -q .
test -f "$environment_file" test -f "$environment_file"
if systemctl is-active --quiet wechat-local-ocr-recorder.service \ if systemctl is-active --quiet wechat-local-ocr-recorder.service \
@@ -276,6 +282,8 @@ jwebot_log "user runtime backup starting path=$backup_dir"
install -d -m 0700 "$target_dir" "$unit_dir" "$config_dir" "$backup_dir" install -d -m 0700 "$target_dir" "$unit_dir" "$config_dir" "$backup_dir"
save_unit_states save_unit_states
for name in \ for name in \
VERSION \
CHANGELOG.md \
"$ocr_script_name" \ "$ocr_script_name" \
"$script_name" \ "$script_name" \
"$adaptive_script_name" \ "$adaptive_script_name" \
@@ -326,6 +334,8 @@ systemctl --user stop \
>/dev/null 2>&1 || true >/dev/null 2>&1 || true
jwebot_log "user runtime files install starting" jwebot_log "user runtime files install starting"
install -m 0600 "$source_dir/VERSION" "$target_dir/VERSION"
install -m 0600 "$source_dir/CHANGELOG.md" "$target_dir/CHANGELOG.md"
install -m 0700 "$runtime_source_dir/$ocr_script_name" "$target_dir/$ocr_script_name" install -m 0700 "$runtime_source_dir/$ocr_script_name" "$target_dir/$ocr_script_name"
install -m 0700 "$runtime_source_dir/$script_name" "$target_dir/$script_name" install -m 0700 "$runtime_source_dir/$script_name" "$target_dir/$script_name"
install -m 0700 "$runtime_source_dir/$adaptive_script_name" "$target_dir/$adaptive_script_name" install -m 0700 "$runtime_source_dir/$adaptive_script_name" "$target_dir/$adaptive_script_name"
@@ -342,7 +352,9 @@ install -d -m 0700 \
"$target_dir/jwebot_sdk" \ "$target_dir/jwebot_sdk" \
"$target_dir/jwebot_kernel" \ "$target_dir/jwebot_kernel" \
"$target_dir/jwebot_official_extensions" \ "$target_dir/jwebot_official_extensions" \
"$target_dir/jwebot_official_extensions/adapters" "$target_dir/jwebot_official_extensions/adapters" \
"$target_dir/jwebot_official_extensions/data" \
"$target_dir/jwebot_official_extensions/data/cs_autochess"
for path in "$config_source_dir"/*.py; do for path in "$config_source_dir"/*.py; do
install -m 0600 "$path" "$target_dir/jwebot_config/$(basename "$path")" install -m 0600 "$path" "$target_dir/jwebot_config/$(basename "$path")"
done done
@@ -359,6 +371,10 @@ for path in "$official_adapter_source_dir"/*.py; do
install -m 0600 "$path" \ install -m 0600 "$path" \
"$target_dir/jwebot_official_extensions/adapters/$(basename "$path")" "$target_dir/jwebot_official_extensions/adapters/$(basename "$path")"
done done
for path in "$official_autochess_data_source_dir"/*.json; do
install -m 0600 "$path" \
"$target_dir/jwebot_official_extensions/data/cs_autochess/$(basename "$path")"
done
for name in \ for name in \
"$service_name" \ "$service_name" \
"$timer_name" \ "$timer_name" \
+122
View File
@@ -0,0 +1,122 @@
# JWeBot Command Catalog
Current baseline: `beta 1.0.0.1016`.
This catalog records the reviewed commands shipped with the built-in local
extensions. Runtime ownership is frozen at startup from the generated
descriptor inventory; plugin code delegates to reviewed service profiles.
## System
| Command | Alias | Purpose |
|---|---|---|
| `/功能列表` | `/help` | Show the command list. |
| `/状态` | `/status` | Show local runtime status. |
| `/version` | - | Show installed package versions plus the latest changelog date and summary. |
| `/debug` | - | Show privacy-safe telemetry diagnostics. |
| `/余额` | - | Show configured direct-provider balance status. |
## Context And People
| Command | Alias | Purpose |
|---|---|---|
| `/上下文 [100|200|300]` | `/context` | Summarize recent local text in the current group. |
| `/人物列表 [页码]` | `/people` | List known group-scoped people. |
| `/人物 [名称或别名]` | `/person` | Look up one configured or observed person. |
| `/记住 内容` | - | Store a local memory entry for the current person. |
| `/我的记忆` | - | Show the current person's memory entries. |
| `/人物记忆 显示名` | - | Show memory entries for a person. |
| `/忘记 关键词` | - | Remove matching local memory entries. |
| `/记忆帮助` | - | Show memory command help. |
## Group Insights
| Command | Alias | Purpose |
|---|---|---|
| `/扫码排行` | - | Show scan ranking for the current group. |
| `/复读榜` | - | Show repeat ranking for the current group. |
| `/查记录 关键词` | - | Search local group history by keyword. |
## Virtual Coins And Games
| Command | Alias | Purpose |
|---|---|---|
| `/金币` | `/wallet` | Initialize or show the current person's virtual coin account. |
| `/大富翁榜 [人数]` | `/金币排行`, `/金币排行榜`, `/richlist` | Show the group-scoped coin leaderboard. |
| `/转账 对方 金额` | `/transfer name amount` | Transfer virtual coins to an initialized group account. |
| `/贷款 [金额]` | `/loan amount`, `/借款` | Borrow up to 500 virtual coins. |
| `/还款 [金额|全部]` | `/repay [amount|all]` | Repay an active virtual coin loan. |
| `/抽奖 [金额]` | `/draw` | Play a single-player prize draw. |
| `/彩票 开始` | `/lottery start` | Open a multiplayer lottery round. |
| `/彩票 报名 数字 金额` | `/lottery join` | Join or update a lottery entry. |
| `/彩票 状态` | `/lottery status` | Show the current lottery round. |
| `/彩票 开奖` | `/lottery draw` | Host-only draw for the current lottery round. |
| `/彩票 取消` | `/lottery cancel` | Host-only cancellation with refunds. |
| `/21点 开始 [1-500]` | `/blackjack start` | Start a blackjack round with the chosen stake. |
| `/21点 要牌` | `/blackjack hit` | Draw one blackjack card. |
| `/21点 停牌` | `/blackjack stand` | Stand and settle against the dealer. |
| `/21点 状态` | `/blackjack status` | Show the current blackjack round. |
| `/21点 结束` | `/blackjack end` | End the current blackjack round. |
| `/游戏记录 [页码]` | `/gamehistory` | Show indexed personal game history. |
| `/游戏帮助` | `/games` | Show local game rules. |
## Random And Session Games
| Command | Alias | Purpose |
|---|---|---|
| `/丢骰子 [NdM]` | - | Roll dice. |
| `/丢骰子挑战` | `/骰子挑战` | Run a dice challenge. |
| `/翻硬币` | - | Flip a coin. |
| `/随机数 [范围]` | - | Draw a random number. |
| `/抽签 [内容...]` | - | Draw lots from provided options. |
| `/今日运势` | - | Show a deterministic daily fortune. |
| `/猜数字 开始 [上限]` | - | Start a group guessing game. |
| `/猜数字 数字` | - | Guess in the active guessing game. |
| `/石头剪刀布 开始` | - | Start a rock-paper-scissors session. |
| `/石头剪刀布 加入` | - | Join the active session. |
| `/石头剪刀布 出 石头|剪刀|布` | - | Submit a move. |
| `/石头剪刀布 状态` | - | Show session status. |
| `/石头剪刀布 结算` | - | Settle the session. |
| `/石头剪刀布 结束` | - | End the session. |
## Steam
| Command | Alias | Purpose |
|---|---|---|
| `/steam 在线` | - | Show configured Steam online status. |
| `/steam 游戏` | - | Show current games. |
| `/steam 全部` | - | Show full configured Steam presence. |
| `/steam 刷新` | - | Refresh cached Steam data. |
| `/steam 时长 游戏名` | `/steam时长游戏名` | Query playtime for a game. |
| `/steam时长游戏名、玩家名` | `/steam时长游戏名人=玩家名` | Query one player's game playtime. |
## CS Major Autochess
| Command | Alias | Purpose |
|---|---|---|
| `/自走棋开始` | - | Create a new IEM 盐田 Major season. |
| `/自走棋报名` | - | Register the current player. |
| `/自走棋加AI战队` | - | Add local deterministic AI teams for testing. |
| `/自走棋开赛` | - | Start the event from registration. |
| `/自走棋状态` | - | Show the current event state. |
| `/自走棋档案` | - | Show archived event information. |
| `/选1` ... `/选10` | - | Draft coaches, players, or shop cards depending on phase. |
| `/刷新` | - | Refresh the shop. |
| `/锁定` | - | Lock or unlock the shop. |
| `/阵容` | - | Show lineup, bench, coach, gold, and synergies. |
| `/赛程` | - | Show the current event schedule. |
| `/升级教练` | - | Upgrade the team's coach level when rules allow it. |
| `/上阵 备战编号 [首发位]` | - | Move a bench player into the starting lineup. |
| `/下阵 首发位` | - | Move a starting player out when rules allow it. |
| `/出售 备战编号` | - | Sell a bench player. |
| `/战术 火力|稳定|残局` | - | Set the team's tactical focus. |
| `/结束回合` | - | Mark readiness and advance when required players are ready. |
## Notes
- Commands must be sent in the configured WeChat group and normally mention the
bot, depending on deployment configuration.
- OCR whitespace loss is tolerated for selected compact forms such as
`/steam时长Dota2` and `/21点开始100`.
- Real profiles, Steam IDs, person context, message payloads, and secrets are
never stored in this repository.
+4 -3
View File
@@ -1,7 +1,7 @@
# Command Registration And Execution Paths # Command Registration And Execution Paths
Status: authoritative local registration and routing contract for Status: authoritative local registration and routing contract for
`alpha 0.6.x`. `beta 1.0.x`.
## Three Execution Paths ## Three Execution Paths
@@ -46,7 +46,7 @@ because each bubble position still produces its own `IncomingMessage`.
Changed pixels force fresh OCR. The cache is bounded and periodically Changed pixels force fresh OCR. The cache is bounded and periodically
revalidated. revalidated.
For the three local plugins added in `alpha 0.6.1.1015`, including bilingual For the local plugins introduced during the 0.6.x line, including bilingual
commands, person identity limitations and atomic single-player game settlement, commands, person identity limitations and atomic single-player game settlement,
see [local plugin rules](local-plugins-0.6.1.md). see [local plugin rules](local-plugins-0.6.1.md).
@@ -146,7 +146,8 @@ descriptors unless they are strictly Kernel-only. The current official
operational commands are: operational commands are:
- `/状态` and `/status`: server, Edge, OCR, and queue status. - `/状态` and `/status`: server, Edge, OCR, and queue status.
- `/version`: installed JWeBot package versions. - `/version`: installed JWeBot package versions plus the latest changelog date
and summary.
- `/debug`: recent privacy-safe telemetry p50/p95/max summary. - `/debug`: recent privacy-safe telemetry p50/p95/max summary.
These commands delegate to Kernel-owned service profiles through generated These commands delegate to Kernel-owned service profiles through generated
+678
View File
@@ -0,0 +1,678 @@
# CS Major 自走棋设计稿
本文档定义一个完全本地、确定性结算、以 CS 职业选手为棋子的群聊
小游戏方案。赛事包装统一为“第 n 届 IEM 盐田 Major”。AI 默认负责主持、
裁判、播报和剧情生成;主持人也可显式加入一支本地确定性 AI 战队。
## 1. 设计目标
1. 做成一个多人报名、多人运营、多人对战的群聊自走棋。
2. 玩法重心放在每轮选秀和阵容运营,而不是复杂微操。
3. 用 Major 赛事包装训练赛、小组单循环、淘汰赛和终局剧情。
4. 结算逻辑必须完全本地、可重放、可持久化、可回档验证。
5. 每一段剧情都要落档,且剧情只能在数值结算之后生成。
## 2. 核心体验
一句话定义:
`三轮赛前选秀 -> 轮次训练赛拿资源 -> D 牌与布阵 -> BO3 小组单循环 -> BO5 淘汰赛 -> 保存每轮赛报与剧情`
玩家感受到的乐趣应当主要来自:
- 第一轮从固定 10 人教练池里抢体系教练;
- 第二、三轮公开选手池里抢关键卡;
- 决定要不要保经济还是强行 D 牌;
- 赌阵容成型时点;
- 看小组单循环和淘汰赛赛报;
- 看最终冠军剧情与本届 Major 档案。
## 3. 对局对象与规模
### 3.1 报名
- 开局命令由任意玩家发起。
- 其他玩家在报名窗口内加入。
- 建议一局支持 `2~8` 名真人玩家。
- 主持人可在报名阶段发送 `/自走棋加AI战队`:
- 两名真人时加入 AI 战队 A、B;
- 三名真人时加入一支 AI 战队。
- 两种测试模式都补满四队,先进行 BO3 小组单循环,再按排名生成
`1v4`、`2v3` 半决赛。
- AI 占一个参赛席位,但不对应群成员,也不调用在线模型。
- AI 使用确定性规则选秀、买牌、调整首发和选择克制战术;为便于测试,
AI 阵容战力会有明确折扣。
### 3.2 玩家初始状态
每位玩家开局拥有:
- 生命值:`100`
- 金币:`10`
- 教练:赛前第一轮选秀决定
- 初始选手:赛前第二、三轮选秀决定,系统随后补齐阵容
- 阵容位:固定 `5 个选手位 + 1 个教练位`,比赛时必须全部上场
- 备战席:`3 格`
- 连胜/连败计数:`0`
- 本局独立事件档案编号
三轮选秀完成后,系统按已选核心确定性补齐每队 `5 名首发 + 3 名备战`
和常驻教练,保证训练赛开始时就是完整阵容。
## 4. 选手池设计
### 4.1 选手来源
只使用“近期非常出名”的职业选手。第一期池子优先覆盖以下俱乐部与明星:
- Spirit
- Vitality
- Falcons
- G2
- FaZe
- NAVI
- MOUZ
- The MongolZ
- Astralis
- Liquid
- FURIA
- Eternal Fire
选手数据不是对真实比赛数据的一比一复刻,而是基于公众认知做游戏化映射。
0.6.7 起本地维护 `100+` 名棋子和 `18` 名教练元数据;运行期不在线抓取
或改写数值。首轮教练选秀仍固定展示 10 个候选,保证聊天窗口可读。
0.6.9 起,公开游戏内容不再内联在 `cs_autochess.py` 中,而是维护在
`packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/`:
`players.json` 保存选手卡,`coaches.json` 保存教练卡,
`narratives.json` 保存战报和剧情素材,`rules.json` 保存经济、商店、
选秀、羁绊、赛制、MR15/加时和特殊事件规则。运行时启动会校验 schema、
重复 ID、必要字段和叙事素材数量。
### 4.2 费用层级
选手分为 `1~5 费`,越高费越稀有,基础数值和成长更强。
示例分层:
- `5 费`:donk、ZywOo
- `4 费`:m0NESY、NiKo、ropz、frozen
- `3 费`:apEX、b1t、xertioN、w0nderful、malbsMd
- `2 费`:辅助型或中坚选手
- `1 费`:前期过渡与羁绊补齐牌
正式实现时应维护一份固定的选手静态表,不允许运行期随意漂移。
### 4.3 定位
只保留三个核心定位:
- `步枪手`
- `狙击手`
- `指挥`
定位不是羁绊,而是战斗中的属性倾向与技能触发来源。
### 4.4 羁绊
只保留两类羁绊:
1. `战队羁绊`
2. `国家羁绊`
战队羁绊可来自:
- 当前主战队;
- 历史待过的关键战队;
- 每名选手最多挂 `3` 条战队标签,避免过度泛化。
国家羁绊只有 `1` 条。
## 5. 选手数值
### 5.1 基础数值轴
为避免面板过宽,第一期只展示三个主数值:
- `火力`
- `稳定`
- `残局`
其中指挥位额外拥有隐藏或显式的 `指挥` 修正值,用于影响全队。
### 5.2 数值范围
- 1 费:`52~68`
- 2 费:`60~76`
- 3 费:`68~84`
- 4 费:`78~92`
- 5 费:`88~98`
要求同费位内部也要有明显差异,不能所有 4 费都只有一两点波动。
### 5.3 派生战斗权重
可先采用以下简单模型:
`单卡战力 = 火力 * 0.45 + 稳定 * 0.30 + 残局 * 0.25 + 星级修正 + 羁绊修正 + 教练修正`
若定位是狙击手,可给火力更高上限;
若定位是指挥,可给团队修正更高权重。
### 5.4 属性与定位克制
战斗不能只比较总战力后掷骰子。三项主属性形成闭环:
- `稳定克火力`:稳定阵容能吸收强攻的第一波冲击;
- `火力克残局`:前期人数优势会压缩残局发挥空间;
- `残局克稳定`:高残局能力能打破按部就班的默认局面。
三个定位也形成轻量闭环:
- `狙击手克步枪手`
- `步枪手克指挥`
- `指挥克狙击手`
玩家每回合可选择 `/战术 火力|稳定|残局`。战术选择会强化对应属性,
并在克制对方战术时获得额外修正。属性、定位和战术的总克制修正上限
应明显高于随机波动,让调阵和判断对手比运气更重要。
## 6. 升星规则
- `3 张同名 = 2 星`
- `9 张同名 = 3 星`
升星收益建议:
- 2 星:基础战力乘 `1.35`
- 3 星:基础战力乘 `1.80`
升星后保留原有羁绊与定位,不改变标签。
## 7. 教练系统
教练位不是普通战斗单位,但始终与 5 名选手一起上场。
### 7.1 教练等级作用
教练只提供额外的全队战术加成,不控制上场人数,也不占用选手位。
建议加成:
- 1 级:全队战力 `+2%`
- 2 级:全队战力 `+4%`
- 3 级:全队战力 `+6%`
- 4 级:全队战力 `+8%`
- 5 级:全队战力 `+10%`
### 7.2 教练获取
教练由第一轮公共选秀决定,本届赛事内不能用金币升级教练。不同教练
自带等级、属性倾向和全队加成。
高费选手的商店概率随赛事进程推进,不再绑定教练等级。
## 8. 每轮 6 人选秀
这是本模式的核心。
### 8.1 固定规则
- 每轮只展示 `6` 名候选选手。
- 这 `6` 人对所有玩家公开可见。
- 每位玩家始终保有 5 名首发;购买的新选手进入备战席或与同名选手合成。
- 调整阵容时必须用备战席选手替换首发,不能让首发少于 5 人。
- 第一版采用“公开同池、各自购买”的简化方案:
- 所有人看到同一组 6 人;
- 某个玩家选择后,不会把该候选从其他玩家界面里抢空;
- 但底层共享总卡池仍然会影响后续刷新概率。
- 每名候选都必须展示完整数据:
- 编号 `1~6`
- 选手名
- 费用
- 定位
- 国家
- 主战队
- 历史战队
- 主数值
- 玩家通过编号选择,不输入复杂自然语言。
### 8.2 选秀指令
建议指令面如下:
```text
/自走棋开始
/自走棋报名
/自走棋加AI战队
/自走棋状态
/选1
/选2
/选3
/选4
/选5
/选6
/选7
/选8
/选9
/选10
/刷新
/锁定
/战术 火力|稳定|残局
/上阵 备战编号 [首发位]
/出售 备战编号
/阵容
/赛程
/结束回合
```
中文是主命令,英文别名可以后补。
### 8.3 刷新与锁定
- 第一轮教练选秀展示固定 10 名教练;第二、三轮选手选秀展示 6 名选手。
- 训练赛和正赛运营阶段默认展示一组 6 人商店。
- 玩家可支付金币执行刷新。
- 玩家可锁定本轮 6 人,使下一轮仍保留同一组候选。
### 8.4 顺位
- 开赛后三轮公共选秀,第一轮选教练,第二、三轮选选手。
- 第二轮使用反向顺序,避免种子靠前者连续先手。
- AI 轮次会自动选择,真人只在轮到自己时使用 `/选N`。
第一期建议先做“公开同池、各自结算”,避免群聊交互过重。
## 9. 回合流程
建议一整回合拆成五段:
1. `回合开始播报`
2. `6 人选秀 / 刷新 / 升级 / 调阵`
3. `训练赛 / 赛事挑战 / PvP 结算`
4. `回合结算与经济发放`
5. `剧情归档`
## 10. 训练赛与赛事挑战
训练赛不再是阈值型 PvE。它是正式小组赛前的多轮真实对阵,用来给玩家
检验阵容、获取金币并自然插入 D 牌窗口。媒体日和赞助挑战仍作为赛事内
插曲出现,但只提供资源变动,不淘汰玩家。
### 10.1 活动类型
- 训练赛
- 媒体日
- 赞助挑战
- 本地陪练队热身
### 10.2 训练赛作用
训练赛按参赛队伍生成 round-robin 轮次。四队测试局会有三轮训练赛,
每队在训练阶段遇到另外三支队伍;奇数队伍时轮空队会与本地陪练队热身。
训练赛只发放金币,不记录小组胜负,也不扣生命。
每轮训练赛结束后进入运营窗口:
- `/刷新` 即 D 牌,消耗金币换一组 6 人商店;
- `/选1~/选6` 买入商店选手,重复卡自动累积星级;
- `/锁定` 保留当前商店;
- `/上阵` 调整 5 人首发;
- `/战术 火力|稳定|残局` 改变下一场的回合倾向。
### 10.3 训练赛结算逻辑
训练赛使用与正式比赛相同的单图 MR15 逐回合模拟,只是赛制为 BO1。
胜方获得更多金币,负方也有保底金币,保证测试局能继续运营。
媒体日和赞助挑战根据阵容完整度、教练、战术和本地确定性随机因子发放
金币,但播报不展示“目标/发挥”这类计算日志,只写剧情和奖励。
## 11. PvP 赛制
### 11.1 赛事包装
整局赛事名称固定为:
`第 n 届 IEM 盐田 Major`
### 11.2 按参赛人数生成赛制
- `2 人`:直接进入决赛。
- `3 支队伍`:进行完整单循环,前 2 名进入决赛。
- `4 人`:不打小组赛,按报名种子直接进行 `1v4`、`2v3` 半决赛。
- `5~8 人`:进行完整小组单循环,每两支队伍只交手一次,前 4 名晋级。
奇数队伍的小组比赛日会有一队轮空,但不会获得胜场。
小组赛每场采用 `BO3`。
### 11.3 小组排名
小组赛首先按胜场排序;同胜场依次比较:
1. 相互交手结果;
2. 当前完整五人阵容战力;
3. 剩余生命值;
4. 固定种子编号。
所有配对在开赛时一次性生成并落档,不允许运行中根据结果重排赛程。
### 11.4 淘汰赛
- 四强固定为 `小组第 1 vs 第 4`、`第 2 vs 第 3`。
- 3 人赛制由小组第 1、2 名直接进入决赛。
- 4 人赛制使用报名种子 `1v4`、`2v3`。
- 2 人赛制直接进入决赛。
- 半决赛胜者进入总决赛,决出本届冠军。
- 半决赛和总决赛均采用 `BO5`。
## 12. 战斗结算
### 12.1 目标
战斗要做到:
- 本地确定性;
- 单轮可解释;
- 支持战报;
- 不依赖在线模型。
### 12.2 简化模型
建议第一期采用“阵容总分 + 对位修正”的轻量模型。
#### 步骤 1:计算阵容基础战力
`阵容基础战力 = 5 名首发选手战力总和`
#### 步骤 2:加上团队修正
团队修正来源:
- 战队羁绊
- 国家羁绊
- 教练等级提供的全队百分比加成
- 装备/战术卡
- 连胜或特殊事件 buff
#### 步骤 3:计算克制修正
依次计算:
1. `稳定 > 火力 > 残局 > 稳定` 的属性对位;
2. `狙击手 > 步枪手 > 指挥 > 狙击手` 的定位对位;
3. 双方本回合主动选择的战术对位。
克制结果必须进入结构化战报,明确展示双方修正值。
#### 步骤 4:加入小幅波动
只允许 `-2% ~ +2%` 的确定性哈希波动。单次运气不能覆盖成型阵容的
属性、定位和战术克制优势。
#### 步骤 5:计算比分
单图采用 `MR15`:
- 常规时间先到 `16` 分获胜;
- 常规时间 `15-15` 进入加时;
- 加时可产生 `19-17`,多轮加时可产生 `22-20` 等比分;
- 每次加时除结构化单图结果外,必须额外生成一段加时剧情并落档。
小组赛按 BO3 先赢 2 图,淘汰赛按 BO5 先赢 3 图。系列赛战报必须展示
大比分、每张图比分、加时次数和克制修正。
## 13. 剧情系统与落档
这是本模式的强制要求。
### 13.1 归档原则
必须先结算,后写剧情。
即:
1. 固化数值结果;
2. 存档回合事实;
3. 基于事实生成文字赛报;
4. 再把文字赛报作为附加档案保存。
### 13.2 必须落档的节点
- 开局报名完成
- 每轮 6 人选秀结果
- 每次刷新和关键升星
- 每轮训练赛和赛事挑战结果
- 每场 PvP 结果
- 小组赛排名、晋级和淘汰节点
- 淘汰赛对阵与结果
- 冠军总结
### 13.3 档案结构
建议至少保存两类记录:
1. `结构化事件档`
2. `文本剧情档`
结构化事件档用于回放和调试;
文本剧情档用于玩家阅读。
示例:
```json
{
"run_id": "major_0001",
"round": 4,
"phase": "group_2",
"event_type": "pvp_result",
"player_a": "Jacky",
"player_b": "Alice",
"winner": "Jacky",
"score_band": "close_win",
"roster_a_power": 418,
"roster_b_power": 403
}
```
## 14. 数据持久化设计
建议新增以下 SQLite 表。
### 14.1 对局主表
`autochess_runs`
- run_id
- group_fingerprint
- season_number
- state
- created_at
- updated_at
### 14.2 玩家状态表
`autochess_players`
- run_id
- person_key
- display_name
- hp
- gold
- coach_level
- coach_xp
- wins
- losses
- streak
- board_json
- bench_json
- shop_json
- locked_shop
### 14.3 选秀记录表
`autochess_shop_events`
- run_id
- round_number
- person_key
- visible_pool_json
- action_type
- action_payload_json
- created_at
### 14.4 战斗结果表
`autochess_battles`
- run_id
- round_number
- phase
- battle_type
- player_a
- player_b
- result_json
- created_at
### 14.5 剧情归档表
`autochess_story_logs`
- run_id
- round_number
- phase
- story_type
- story_text
- facts_json
- source_event_id
- created_at
### 14.6 幂等与重放表
`autochess_action_replay`
- run_id
- person_key
- round_number
- event_id
- action_kind
- response_text
- created_at
## 15. 回放与幂等
因为群聊环境会出现重复事件、OCR 重读和命令重放,必须保证:
- 同一事件只执行一次;
- 同一回合不能重复发奖或重复扣款;
- 同一段剧情不能重复写多份版本;
- 回合失败时整段事务回滚。
建议沿用现有本地游戏的幂等思路,为自走棋单独增加 action replay 表。
## 16. 指令交互风格
建议所有回复都遵循现有小游戏风格:
多段回复优先。一次主持动作可以拆成欢迎语、参赛名单、种子顺序、候选池、
状态面板、操作指引等多条消息,每条保持在发送上限内。
例如:
```text
欢迎来到第一轮选秀,本轮选教练。
选择顺序:Jacky、Alice、AI 战队 A、AI 战队 B
1. zonic|5级教练|冠军体系|火78 稳92 残88|全队+9%
...
现在轮到 @Jacky 做出选择,使用 /选1~/选10。
```
### 16.1 Taffy 主持状态机
主持人必须承担完整赛事主持,而不是只在结算时出现;输出不需要每句都带
`Taffy:` 前缀。
开赛时必须包含:
- `欢迎大家来到第 n 届 IEM 盐田 Major`
- 本届参赛者和种子
- 当前人数对应的完整赛制
- 小组赛 BO3、淘汰赛 BO5、单图 MR15 和加时规则
- 三轮选秀规则和当前选择玩家
- 训练赛、小组赛和淘汰赛的当前阶段
赛前选秀按顺序逐人选择。进入阵容准备、训练赛运营和小组赛运营后,
所有相关真人发送 `/结束回合` 即可自动推进,不需要主持人手动结算。
淘汰赛只等待当前对阵中的真人,已经淘汰的玩家不会再被提示准备。
```text
Jacky 选择了 zonic!
现在轮到 @Alice 做出选择,使用 /选1~/选10。
训练赛第 1/3 轮对阵:Team Jacky vs Team Alayak;AI 战队 A vs AI 战队 B
本轮训练结束。现在是运营窗口:/刷新 是 D 牌,/选1~/选6 是买店内选手;
所有真人发送 /结束回合 后进入下一轮训练。
```
以下节点都必须由 Taffy 主持并落档:
- 报名开启和新选手加入
- AI 测试战队加入
- 赛制与种子公布
- 三轮选秀候选池、选择结果和选秀完成
- 每轮训练赛对阵、比分、金币和运营提示
- 媒体日或赞助挑战
- 每场 BO3/BO5 对阵与结果
- 每张加时地图
- 小组排名和前四晋级
- 1v4、2v3 半决赛
- 总决赛和冠军颁奖
## 17. 实现顺序建议
建议按三个阶段落地。
### 第一阶段:最小可玩版
- 建局 / 报名 / 状态
- 每轮 6 人选秀
- 买卡、卖卡、升星
- 教练等级
- 简化训练赛
- 简化 PvP
- 基础剧情归档
### 第二阶段:赛事化增强
- 小组单循环完整配对
- 1v4、2v3 淘汰赛树
- 羁绊与装备系统细化
- 战报模板优化
### 第三阶段:内容扩展
- 在 100+ 人本地卡池基础上增加新赛季棋子
- 更多俱乐部和国家
- 特殊赛事事件
- 更细的剧情风格与本届 Major 年鉴
## 18. 第一版明确不做的内容
为保证能上线,第一版先不做:
- 实时地图微操
- 复杂技能动画
- 过多职业分类
- 在线爬取实时战队数据
- 大量开放文本输入解析
## 19. 最终结论
这套模式最适合按“强运营、轻操作、重赛报”路线实现。
最重要的三个锚点已经确定:
1. `每轮固定 6 人公开选秀`
2. `训练赛与挑战负责资源,PvP 负责小组单循环与淘汰赛`
3. `每一段剧情都必须落档`
如果按这个设计推进,实现出来的成品会更像一场可回放、可复盘、
可连载的本地 Major 模拟器,而不是一个只有数值结算的普通抽卡游戏。
+20 -2
View File
@@ -8,6 +8,9 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,
|---|---|---| |---|---|---|
| context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` | | context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` |
| single-player-games | `/金币` | `/wallet` | | single-player-games | `/金币` | `/wallet` |
| coin-ranking | `/大富翁榜`、`/金币排行` | `/richlist` |
| coin-transfer | `/转账 对方 金额` | `/transfer name amount` |
| coin-loan | `/贷款 [金额]`、`/还款 [金额\|全部]` | `/loan amount`、`/repay [amount\|all]` |
| single-player-games | `/抽奖 [金额]` | `/draw [金额]` | | single-player-games | `/抽奖 [金额]` | `/draw [金额]` |
| single-player-games | `/彩票 开始/报名/当前状态/开奖/取消` | `/lottery start/join/status/draw/cancel` | | single-player-games | `/彩票 开始/报名/当前状态/开奖/取消` | `/lottery start/join/status/draw/cancel` |
| single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) | | single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) |
@@ -49,8 +52,10 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使
仓库没有生产人物或映射文件。 仓库没有生产人物或映射文件。
昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域; 昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域;
应优先追加别名而不是重命名。用户不能通过游戏命令指定他人的账户, 应优先追加别名而不是重命名。除 `/转账` 外,用户不能通过游戏命令
不能充值、转账、赠币或兑换真实资产。 指定他人的账户;转账只支持同群虚拟金币账户,收款人需先发送 `/金币`
初始化。`/贷款` 和 `/还款` 只作用于本人账户。虚拟金币不能充值、
赠币或兑换真实资产。
## 虚拟金币与游戏管理 ## 虚拟金币与游戏管理
@@ -58,6 +63,19 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使
重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和 重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和
21 点下注为 1~500;余额不足或参数非法不扣款。 21 点下注为 1~500;余额不足或参数非法不扣款。
`/转账 对方 金额` 会把本人的虚拟金币转给同群已初始化账户,英文为
`/transfer name amount`。金额范围 1~100000;不允许给自己转账。
转账、响应记录和重试去重写在同一个 ledger 事务内,同一 event_id
不会重复扣款。
`/贷款 [金额]` 会给本人账户借入 1~500 金币;同一账户同一时间最多
一笔未还贷款。`/还款 [金额|全部]` 从当前金币余额中扣除并减少未还
贷款,借款、还款、响应记录和重试去重同样写在一个 ledger 事务内。
`/大富翁榜` 或 `/金币排行 [人数]` 只读取当前群已有金币账户,不会创建
调用者账户。榜单按当前金币余额降序排列,人数范围 1~20;账户存在
未还贷款时会在名字后标注贷款本金。
抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。 抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。
alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关, alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关,
下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见 下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见
+1 -1
View File
@@ -1,6 +1,6 @@
# Plugin Contract # Plugin Contract
Status: authoritative for the `alpha 0.6.x` extension API. Status: authoritative for the `beta 1.0.x` extension API.
## Scope ## Scope
+5 -3
View File
@@ -6,9 +6,11 @@ exception.
## Release Discipline ## Release Discipline
- Version format is `alpha X.Y.Z.MMDD`. - Version format is `beta X.Y.Z.MMDD` for current beta releases.
- Patch changes increment `Z`; minor version changes require explicit user - Python package versions use the matching PEP 440 beta form, such as
approval. `X.Y.Zb1+MMDD`.
- Patch changes increment `Z`; minor, major, or stable-release changes require
explicit user approval.
- A release change must update: - A release change must update:
- `VERSION`; - `VERSION`;
- root `pyproject.toml`; - root `pyproject.toml`;
+1 -1
View File
@@ -1,6 +1,6 @@
# Runtime Telemetry And ACK Semantics # Runtime Telemetry And ACK Semantics
Status: local instrumentation contract for `alpha 0.6.x`. Status: local instrumentation contract for `beta 1.0.x`.
## What ACK Means ## What ACK Means
+10 -6
View File
@@ -1,20 +1,24 @@
# Versioning # Versioning
JWeBot uses an alpha version string: JWeBot uses a human-readable product version string:
```text ```text
alpha X.Y.Z.MMDD beta X.Y.Z.MMDD
``` ```
Rules: Rules:
- `alpha` means the public API and deployment process may still change. - `beta` means the project has passed the local, staging, privacy, and
production-safety gates expected for wider testing, while still allowing
controlled breaking changes before a stable release.
- `X.Y.Z` is the project version. - `X.Y.Z` is the project version.
- `MMDD` is the delivery date. - `MMDD` is the delivery date.
- Python package versions use PEP 440 pre-release form. For beta builds,
`beta 1.0.0.1016` maps to `1.0.0b1+1016`.
- Normal small updates increment only the patch version, for example - Normal small updates increment only the patch version, for example
`alpha 0.0.1.1014` to `alpha 0.0.2.1014`. `beta 1.0.0.1016` to `beta 1.0.1.1016`.
- Minor versions such as `0.1.x` require explicit user review and milestone - Minor, major, or stable-release changes require explicit user review and
approval. milestone approval.
- Local repository migration does not imply a production deployment. - Local repository migration does not imply a production deployment.
- Production services must not be changed unless the operator explicitly asks - Production services must not be changed unless the operator explicitly asks
for deployment or runtime verification. for deployment or runtime verification.
+22
View File
@@ -0,0 +1,22 @@
# coin-loan
Author: `jwebot`
贷款
Help:
- `/贷款;/还款`
Registered commands:
- `/贷款`
- `/借款`
- `/loan`
- `/borrow`
- `/还款`
- `/repay`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `coin-loan` Kernel service profile
and returns a structured `ReplyPlan`.
+14
View File
@@ -0,0 +1,14 @@
id = "coin-loan"
author = "jwebot"
description = "贷款"
help = ["/贷款;/还款"]
version = "1.0.0"
api_version = "1"
entrypoint = "coin_loan:plugin"
type = "command"
[capabilities]
commands = ["/贷款", "/借款", "/loan", "/borrow", "/还款", "/repay"]
storage = false
network_profiles = []
service_profiles = ["coin-loan"]
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-loan."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-loan'
AUTHOR = 'jwebot'
DESCRIPTION = '贷款'
HELP_ENTRIES = ('/贷款;/还款',)
COMMANDS = ('/贷款', '/借款', '/loan', '/borrow', '/还款', '/repay')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from coin_loan import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '贷款')
self.assertEqual(HELP_ENTRIES, ('/贷款;/还款',))
self.assertIn('/贷款', registered)
self.assertEqual(len(registered), 6)
if __name__ == "__main__":
unittest.main()
+20
View File
@@ -0,0 +1,20 @@
# coin-ranking
Author: `jwebot`
富榜
Help:
- `/大富翁榜`
Registered commands:
- `/金币排行`
- `/金币排行榜`
- `/大富翁榜`
- `/richlist`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `coin-ranking` Kernel service profile
and returns a structured `ReplyPlan`.
@@ -0,0 +1,14 @@
id = "coin-ranking"
author = "jwebot"
description = "富榜"
help = ["/大富翁榜"]
version = "1.0.0"
api_version = "1"
entrypoint = "coin_ranking:plugin"
type = "command"
[capabilities]
commands = ["/金币排行", "/金币排行榜", "/大富翁榜", "/richlist"]
storage = false
network_profiles = []
service_profiles = ["coin-ranking"]
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-ranking."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-ranking'
AUTHOR = 'jwebot'
DESCRIPTION = '富榜'
HELP_ENTRIES = ('/大富翁榜',)
COMMANDS = ('/金币排行', '/金币排行榜', '/大富翁榜', '/richlist')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from coin_ranking import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '富榜')
self.assertEqual(HELP_ENTRIES, ('/大富翁榜',))
self.assertIn('/金币排行', registered)
self.assertEqual(len(registered), 4)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -0,0 +1,18 @@
# coin-transfer
Author: `jwebot`
金币转账
Help:
- `/转账 对方 金额;/transfer`
Registered commands:
- `/转账`
- `/transfer`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `coin-transfer` Kernel service profile
and returns a structured `ReplyPlan`.
@@ -0,0 +1,14 @@
id = "coin-transfer"
author = "jwebot"
description = "金币转账"
help = ["/转账 对方 金额;/transfer"]
version = "1.0.0"
api_version = "1"
entrypoint = "coin_transfer:plugin"
type = "command"
[capabilities]
commands = ["/转账", "/transfer"]
storage = false
network_profiles = []
service_profiles = ["coin-transfer"]
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-transfer."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-transfer'
AUTHOR = 'jwebot'
DESCRIPTION = '金币转账'
HELP_ENTRIES = ('/转账 对方 金额;/transfer',)
COMMANDS = ('/转账', '/transfer')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from coin_transfer import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '金币转账')
self.assertEqual(HELP_ENTRIES, ('/转账 对方 金额;/transfer',))
self.assertIn('/转账', registered)
self.assertEqual(len(registered), 2)
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -0,0 +1,30 @@
# cs-major-autochess
Author: `jwebot`
自走棋
Help:
- `/自走棋 开始|报名|选`
Registered commands:
- `/自走棋`
- `/cs自走棋`
- `/autochess`
- `/选`
- `/刷新`
- `/锁定`
- `/战术`
- `/升级教练`
- `/上阵`
- `/下阵`
- `/出售`
- `/阵容`
- `/赛程`
- `/结束回合`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `cs-major-autochess` Kernel service profile
and returns a structured `ReplyPlan`.
@@ -0,0 +1,14 @@
id = "cs-major-autochess"
author = "jwebot"
description = "自走棋"
help = ["/自走棋 开始|报名|选"]
version = "1.0.0"
api_version = "1"
entrypoint = "cs_major_autochess:plugin"
type = "command"
[capabilities]
commands = ["/自走棋", "/cs自走棋", "/autochess", "/选", "/刷新", "/锁定", "/战术", "/升级教练", "/上阵", "/下阵", "/出售", "/阵容", "/赛程", "/结束回合"]
storage = false
network_profiles = []
service_profiles = ["cs-major-autochess"]
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for cs-major-autochess."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'cs-major-autochess'
AUTHOR = 'jwebot'
DESCRIPTION = '自走棋'
HELP_ENTRIES = ('/自走棋 开始|报名|选',)
COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from cs_major_autochess import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '自走棋')
self.assertEqual(HELP_ENTRIES, ('/自走棋 开始|报名|选',))
self.assertIn('/自走棋', registered)
self.assertEqual(len(registered), 14)
if __name__ == "__main__":
unittest.main()
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
+8
View File
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -2,14 +2,15 @@
Author: `jwebot` Author: `jwebot`
虚拟金币账户、抽奖、群内多人彩票和单人21点 金币游戏
Help: Help:
- `/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory` - `/金币 /wallet;/游戏记录 /gamehistory`
- `/彩票 开始|报名|当前状态|开奖|取消;/lottery` - `/抽奖 [金额];/draw`
- `/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)` - `/彩票 开始|报名|状态|开奖|取消;/lottery`
- `/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end` - `/21点 开始 [1~500];/blackjack start`
- `/21点 要牌|停牌|状态|结束;/blackjack`
- `/游戏帮助;/games` - `/游戏帮助;/games`
Registered commands: Registered commands:
@@ -1,7 +1,7 @@
id = "single-player-games" id = "single-player-games"
author = "jwebot" author = "jwebot"
description = "虚拟金币账户、抽奖、群内多人彩票和单人21点" description = "金币游戏"
help = ["/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory", "/彩票 开始|报名|当前状态|开奖|取消;/lottery", "/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)", "/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", "/游戏帮助;/games"] help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 开始 [1~500];/blackjack start", "/21点 要牌|停牌|状态|结束;/blackjack", "/游戏帮助;/games"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
entrypoint = "single_player_games:plugin" entrypoint = "single_player_games:plugin"
@@ -5,8 +5,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games' PROFILE = 'single-player-games'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点' DESCRIPTION = '金币游戏'
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games') HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -19,8 +19,8 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list for call in context.commands.register.call_args_list
] ]
self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '虚拟金币账户、抽奖、群内多人彩票和单人21点') self.assertEqual(DESCRIPTION, '金币游戏')
self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')) self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games'))
self.assertIn('/金币', registered) self.assertIn('/金币', registered)
self.assertEqual(len(registered), 15) self.assertEqual(len(registered), 15)
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-config" name = "jwebot-config"
version = "0.6.3a0+1015" version = "1.0.0b1+1016"
description = "Profile-based configuration compiler for JWeBot" description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile", "write_default_profile",
] ]
__version__ = "0.6.3a0+1015" __version__ = "1.0.0b1+1016"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-extension-host" name = "jwebot-extension-host"
version = "0.6.3a0+1015" version = "1.0.0b1+1016"
description = "Single-release, single-invocation process host for JWeBot extensions" description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-sdk==0.6.3a0+1015", "jwebot-sdk==1.0.0b1+1016",
"jwebot-kernel==0.6.3a0+1015", "jwebot-kernel==1.0.0b1+1016",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "0.6.3a0+1015" __version__ = "1.0.0b1+1016"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-kernel" name = "jwebot-kernel"
version = "0.6.3a0+1015" version = "1.0.0b1+1016"
description = "Stable routing and extension authority for JWeBot" description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-config==0.6.3a0+1015", "jwebot-config==1.0.0b1+1016",
"jwebot-sdk==0.6.3a0+1015", "jwebot-sdk==1.0.0b1+1016",
"jwebot-official-extensions==0.6.3a0+1015", "jwebot-official-extensions==1.0.0b1+1016",
"tomli>=2; python_version < '3.11'", "tomli>=2; python_version < '3.11'",
] ]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature", "verify_installed_release_signature",
] ]
__version__ = "0.6.3a0+1015" __version__ = "1.0.0b1+1016"
@@ -61,6 +61,8 @@ from jwebot_official_extensions import (
extension_for_kind, extension_for_kind,
local_command_parts, local_command_parts,
local_extension_command_token, local_extension_command_token,
pack_reply_parts,
unpack_reply_parts,
) )
from jwebot_official_extensions.steam import ( from jwebot_official_extensions.steam import (
DEFAULT_STEAM_MIHOMO_PROXY_URL, DEFAULT_STEAM_MIHOMO_PROXY_URL,
@@ -1070,6 +1072,71 @@ class Ledger:
ON group_lottery_entries( ON group_lottery_entries(
group_fingerprint, round_number, created_at, person_key group_fingerprint, round_number, created_at, person_key
); );
CREATE TABLE IF NOT EXISTS autochess_runs (
group_fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
season_number INTEGER NOT NULL,
state TEXT NOT NULL,
host_person_key TEXT NOT NULL,
state_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, run_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS autochess_one_active_run
ON autochess_runs(group_fingerprint)
WHERE state IN ('registration', 'active');
CREATE TABLE IF NOT EXISTS autochess_action_replay (
group_fingerprint TEXT NOT NULL,
event_id TEXT NOT NULL,
run_id TEXT NOT NULL,
person_key TEXT NOT NULL,
command TEXT NOT NULL,
response_text TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, event_id)
);
CREATE TABLE IF NOT EXISTS autochess_story_logs (
group_fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
story_id TEXT NOT NULL,
round_number INTEGER NOT NULL,
phase TEXT NOT NULL,
story_type TEXT NOT NULL,
story_text TEXT NOT NULL,
facts_json TEXT NOT NULL,
source_event_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, run_id, story_id),
FOREIGN KEY (group_fingerprint, run_id)
REFERENCES autochess_runs(group_fingerprint, run_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS autochess_battles (
group_fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
battle_id TEXT NOT NULL,
round_number INTEGER NOT NULL,
phase TEXT NOT NULL,
battle_type TEXT NOT NULL,
player_a TEXT NOT NULL,
player_b TEXT NOT NULL,
result_json TEXT NOT NULL,
source_event_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, run_id, battle_id),
FOREIGN KEY (group_fingerprint, run_id)
REFERENCES autochess_runs(group_fingerprint, run_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS autochess_story_run_round
ON autochess_story_logs(
group_fingerprint, run_id, round_number, story_id
);
CREATE INDEX IF NOT EXISTS autochess_battle_run_round
ON autochess_battles(
group_fingerprint, run_id, round_number, battle_id
);
CREATE INDEX IF NOT EXISTS events_group_created CREATE INDEX IF NOT EXISTS events_group_created
ON events(group_fingerprint, created_at); ON events(group_fingerprint, created_at);
CREATE INDEX IF NOT EXISTS outbox_due CREATE INDEX IF NOT EXISTS outbox_due
@@ -3598,6 +3665,206 @@ class Ledger:
"game_counts": dict(account["game_counts"]), "game_counts": dict(account["game_counts"]),
} }
def player_coin_ranking(
self,
group: str,
*,
limit: int = 10,
) -> list[dict[str, Any]]:
group = bounded_text(group, limit=256)
self.seed_person_aliases(group)
with self.lock:
account_rows = [
dict(row)
for row in self.connection.execute(
"""
SELECT person_key, state_json, updated_at
FROM player_accounts
WHERE group_fingerprint = ?
""",
(group,),
).fetchall()
]
alias_rows = [
dict(row)
for row in self.connection.execute(
"""
SELECT DISTINCT canonical_name
FROM person_aliases
WHERE group_fingerprint = ?
""",
(group,),
).fetchall()
]
profile_rows = [
dict(row)
for row in self.connection.execute(
"""
SELECT person_key, display_name
FROM person_profiles
WHERE group_fingerprint = ?
""",
(group,),
).fetchall()
]
observation_rows = [
dict(row)
for row in self.connection.execute(
"""
SELECT person_key, display_name
FROM person_observations
WHERE group_fingerprint = ?
ORDER BY observed_at DESC, created_at DESC
""",
(group,),
).fetchall()
]
labels: dict[str, str] = {}
for row in alias_rows:
canonical = bounded_text(row["canonical_name"], limit=120)
if canonical:
labels[person_scope_key(group, canonical)] = person_display_label(
canonical
)
for row in profile_rows:
person_key = str(row["person_key"])
labels.setdefault(
person_key,
person_display_label(row["display_name"]),
)
for row in observation_rows:
person_key = str(row["person_key"])
labels.setdefault(
person_key,
person_display_label(row["display_name"]),
)
records: list[dict[str, Any]] = []
for row in account_rows:
person_key = str(row["person_key"])
try:
account = json.loads(str(row["state_json"]))
except json.JSONDecodeError as error:
raise LocalCommandError("金币账户状态无效") from error
if not isinstance(account, dict):
raise LocalCommandError("金币账户状态无效")
normalize_player_account(account)
loan = account.get("loan")
loan_principal = (
int(loan.get("principal", 0))
if isinstance(loan, dict)
else 0
)
records.append(
{
"display_name": labels.get(
person_key,
f"玩家 {person_key[:8]}",
),
"balance": int(account["balance"]),
"loan": loan_principal,
"updated_at": str(row["updated_at"]),
}
)
records.sort(
key=lambda item: (
-int(item["balance"]),
str(item["display_name"]).casefold(),
)
)
return records[: max(1, min(int(limit), 20))]
def transfer_player_coins(
self,
group: str,
sender: str,
event_id: str,
command: str,
target_label: str,
amount: int,
render: Callable[[dict[str, Any]], str],
) -> str:
"""Move virtual coins between two group-scoped player accounts."""
source = self._player_identity(group, sender)
source_key = str(source["person_key"])
target = self.resolve_person(group, target_label)
target_key = str(target["person_key"])
target_display_name = person_display_label(target["display_name"])
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
replay = self._player_action_replay(
group, event_id, source_key, command
)
if replay is not None:
self.connection.execute("COMMIT")
return replay
if source_key == target_key:
data: dict[str, Any] = {
"status": "self",
"source_display_name": source["display_name"],
"target_display_name": target_display_name,
"amount": amount,
}
else:
target_exists = self.connection.execute(
"""
SELECT 1 FROM player_accounts
WHERE group_fingerprint = ? AND person_key = ?
LIMIT 1
""",
(group, target_key),
).fetchone() is not None
if not target_exists:
data = {
"status": "target_missing",
"source_display_name": source["display_name"],
"target_display_name": target_display_name,
"amount": amount,
}
else:
source_account = self._load_player_account(
group, source_key
)
if amount > int(source_account["balance"]):
data = {
"status": "insufficient",
"source_display_name": source["display_name"],
"target_display_name": target_display_name,
"amount": amount,
"source_balance": int(source_account["balance"]),
}
else:
target_account = self._load_player_account(
group, target_key
)
source_account["balance"] -= amount
target_account["balance"] += amount
timestamp = utc_now()
self._save_player_account(
group, source_key, source_account, timestamp
)
self._save_player_account(
group, target_key, target_account, timestamp
)
data = {
"status": "transferred",
"source_display_name": source["display_name"],
"target_display_name": target_display_name,
"amount": amount,
"source_balance": int(source_account["balance"]),
"target_balance": int(target_account["balance"]),
}
response = render(data)
timestamp = utc_now()
self._store_player_action(
group, event_id, source_key, command, response, timestamp
)
self.connection.execute("COMMIT")
return response
except BaseException:
self.connection.execute("ROLLBACK")
raise
def apply_player_action( def apply_player_action(
self, group: str, sender: str, event_id: str, command: str, self, group: str, sender: str, event_id: str, command: str,
action: Callable[[dict[str, Any]], str], action: Callable[[dict[str, Any]], str],
@@ -3634,6 +3901,282 @@ class Ledger:
self.connection.execute("ROLLBACK") self.connection.execute("ROLLBACK")
raise raise
def _autochess_action_replay(
self,
group: str,
event_id: str,
person_key: str,
command: str,
) -> str | None:
previous = self.connection.execute(
"""
SELECT person_key, command, response_text
FROM autochess_action_replay
WHERE group_fingerprint = ? AND event_id = ?
""",
(group, event_id),
).fetchone()
if previous is None:
return None
if previous["person_key"] != person_key or previous["command"] != command:
raise LocalCommandError("同一事件不能变更自走棋玩家或指令")
return str(previous["response_text"])
def _load_autochess_run(
self,
group: str,
) -> tuple[sqlite3.Row | None, dict[str, Any] | None]:
row = self.connection.execute(
"""
SELECT * FROM autochess_runs
WHERE group_fingerprint = ?
ORDER BY
CASE WHEN state IN ('registration', 'active') THEN 0 ELSE 1 END,
season_number DESC
LIMIT 1
""",
(group,),
).fetchone()
if row is None:
return None, None
try:
state = json.loads(str(row["state_json"]))
except json.JSONDecodeError as error:
raise LocalCommandError("自走棋状态无效") from error
if not isinstance(state, dict):
raise LocalCommandError("自走棋状态无效")
return row, state
@staticmethod
def _validate_autochess_state(state: dict[str, Any]) -> None:
status = state.get("status")
if status not in {"registration", "active", "completed", "cancelled"}:
raise LocalCommandError("自走棋赛事状态无效")
if (
not isinstance(state.get("run_id"), str)
or not state["run_id"]
or type(state.get("season_number")) is not int
or int(state["season_number"]) < 1
or not isinstance(state.get("host_person_key"), str)
or not state["host_person_key"]
or not isinstance(state.get("players"), dict)
):
raise LocalCommandError("自走棋赛事结构无效")
phase = str(state.get("phase", ""))
roster_required = (
status == "completed"
or (
status == "active"
and not phase.startswith("draft_")
and phase != "registration"
)
)
if roster_required:
players = state["players"].values()
if any(
not isinstance(player, dict)
or not isinstance(player.get("board"), list)
or len(player["board"]) != 5
for player in players
):
raise LocalCommandError("自走棋比赛必须保持五人首发")
def apply_autochess_action(
self,
group: str,
sender: str,
event_id: str,
command: str,
action: Callable[
[dict[str, Any] | None, dict[str, str], int],
dict[str, Any],
],
) -> str:
"""Commit autochess state, replay, stories and battles atomically."""
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
replay = self._autochess_action_replay(
group,
event_id,
person_key,
command,
)
if replay is not None:
self.connection.execute("COMMIT")
return replay
stored, state = self._load_autochess_run(group)
next_season = int(
self.connection.execute(
"""
SELECT COALESCE(MAX(season_number), 0) + 1
FROM autochess_runs
WHERE group_fingerprint = ?
""",
(group,),
).fetchone()[0]
)
result = action(state, person, next_season)
if not isinstance(result, dict):
raise LocalCommandError("自走棋动作结果无效")
response = result.get("response")
new_state = result.get("state")
stories = result.get("stories", [])
battles = result.get("battles", [])
if not isinstance(response, str) or not response.strip():
raise LocalCommandError("自走棋响应无效")
if not isinstance(stories, list) or not isinstance(battles, list):
raise LocalCommandError("自走棋归档无效")
timestamp = utc_now()
run_id = str(stored["run_id"]) if stored is not None else ""
if new_state is not None:
if not isinstance(new_state, dict):
raise LocalCommandError("自走棋状态无效")
self._validate_autochess_state(new_state)
run_id = str(new_state["run_id"])
season_number = int(new_state["season_number"])
if (
stored is not None
and str(stored["run_id"]) != run_id
and str(stored["state"]) in {"registration", "active"}
):
archived_state = dict(state or {})
archived_state["status"] = "cancelled"
archived_state["phase"] = "cancelled"
self.connection.execute(
"""
UPDATE autochess_runs
SET state = 'cancelled',
state_json = ?,
updated_at = ?
WHERE group_fingerprint = ? AND run_id = ?
""",
(
canonical_json(archived_state),
timestamp,
group,
str(stored["run_id"]),
),
)
existing = self.connection.execute(
"""
SELECT created_at FROM autochess_runs
WHERE group_fingerprint = ? AND run_id = ?
""",
(group, run_id),
).fetchone()
created_at = (
str(existing["created_at"])
if existing is not None
else timestamp
)
self.connection.execute(
"""
INSERT INTO autochess_runs (
group_fingerprint, run_id, season_number, state,
host_person_key, state_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(group_fingerprint, run_id) DO UPDATE SET
season_number = excluded.season_number,
state = excluded.state,
host_person_key = excluded.host_person_key,
state_json = excluded.state_json,
updated_at = excluded.updated_at
""",
(
group,
run_id,
season_number,
str(new_state["status"]),
str(new_state["host_person_key"]),
canonical_json(new_state),
created_at,
timestamp,
),
)
for item in stories:
if (
not isinstance(item, dict)
or not isinstance(item.get("story_id"), str)
or not isinstance(item.get("text"), str)
or not isinstance(item.get("facts", {}), dict)
):
raise LocalCommandError("自走棋剧情归档无效")
self.connection.execute(
"""
INSERT INTO autochess_story_logs (
group_fingerprint, run_id, story_id,
round_number, phase, story_type, story_text,
facts_json, source_event_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
group,
run_id,
str(item["story_id"]),
int(item.get("round_number", 0)),
str(item.get("phase", "")),
str(item.get("story_type", "")),
str(item["text"]),
canonical_json(item.get("facts", {})),
event_id,
timestamp,
),
)
for item in battles:
if (
not isinstance(item, dict)
or not isinstance(item.get("battle_id"), str)
or not isinstance(item.get("result"), dict)
):
raise LocalCommandError("自走棋战斗归档无效")
self.connection.execute(
"""
INSERT INTO autochess_battles (
group_fingerprint, run_id, battle_id,
round_number, phase, battle_type, player_a,
player_b, result_json, source_event_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
group,
run_id,
str(item["battle_id"]),
int(item.get("round_number", 0)),
str(item.get("phase", "")),
str(item.get("battle_type", "")),
str(item.get("player_a", "")),
str(item.get("player_b", "")),
canonical_json(item["result"]),
event_id,
timestamp,
),
)
self.connection.execute(
"""
INSERT INTO autochess_action_replay (
group_fingerprint, event_id, run_id, person_key,
command, response_text, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
group,
event_id,
run_id,
person_key,
command,
response,
timestamp,
),
)
self.connection.execute("COMMIT")
return response
except BaseException:
self.connection.execute("ROLLBACK")
raise
def status(self) -> dict[str, Any]: def status(self) -> dict[str, Any]:
with self.lock: with self.lock:
events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0] events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0]
@@ -4670,7 +5213,11 @@ class EdgeApplication:
error_type=type(error).__name__, error_type=type(error).__name__,
) )
raise EdgeError("隔离插件暂时不可用") from error raise EdgeError("隔离插件暂时不可用") from error
if result.reply.kind is not PlanKind.TEXT: if result.reply.kind is PlanKind.MULTI_TEXT:
reply_message = pack_reply_parts(list(result.reply.messages))
elif result.reply.kind is PlanKind.TEXT:
reply_message = result.reply.message
else:
raise EdgeError("隔离插件没有返回可见回复") raise EdgeError("隔离插件没有返回可见回复")
self.telemetry.elapsed( self.telemetry.elapsed(
"edge.extension.execute", "edge.extension.execute",
@@ -4680,7 +5227,7 @@ class EdgeApplication:
plugin_id=owner.extension_id, plugin_id=owner.extension_id,
mode="host", mode="host",
) )
return result.reply.message return reply_message
if ( if (
self.config.extension_mode == "host-only" self.config.extension_mode == "host-only"
or ( or (
@@ -4769,13 +5316,18 @@ class EdgeApplication:
"duplicate": True, "duplicate": True,
} }
if row["response_text"]: if row["response_text"]:
return { parts = unpack_reply_parts(row["response_text"])
text = parts[0] if parts is not None else row["response_text"]
response = {
"status": "reply", "status": "reply",
"route": row["route"], "route": row["route"],
"execution_path": stored_execution_path, "execution_path": stored_execution_path,
"text": row["response_text"], "text": text,
"duplicate": True, "duplicate": True,
} }
if parts is not None:
response["texts"] = parts
return response
return { return {
"status": "fallback_cloud", "status": "fallback_cloud",
"reason": "existing_incomplete_event", "reason": "existing_incomplete_event",
@@ -4897,6 +5449,13 @@ class EdgeApplication:
status="local_completed", status="local_completed",
response_text=reply, response_text=reply,
) )
parts = unpack_reply_parts(reply)
visible_reply = parts[0] if parts is not None else reply
reply_chars = (
sum(len(part) for part in parts)
if parts is not None
else len(reply)
)
self.telemetry.elapsed( self.telemetry.elapsed(
"edge.local.complete", "edge.local.complete",
started, started,
@@ -4905,14 +5464,18 @@ class EdgeApplication:
route=route, route=route,
reason=reason, reason=reason,
execution_path=execution_path, execution_path=execution_path,
chars=len(reply), chars=reply_chars,
messages=len(parts) if parts is not None else 1,
) )
return { response = {
"status": "reply", "status": "reply",
"route": route, "route": route,
"execution_path": execution_path, "execution_path": execution_path,
"text": reply, "text": visible_reply,
} }
if parts is not None:
response["texts"] = parts
return response
alias_reply = self.ledger.local_alias_identity_reply( alias_reply = self.ledger.local_alias_identity_reply(
str(row["group_fingerprint"]), str(row["group_fingerprint"]),
str(row["question"]), str(row["question"]),
@@ -4,12 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-official-extensions" name = "jwebot-official-extensions"
version = "0.6.3a0+1015" version = "1.0.0b1+1016"
description = "Reviewed local command adapters and broker implementation for JWeBot" description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-sdk==0.6.3a0+1015", "jwebot-sdk==1.0.0b1+1016",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.setuptools.package-data]
jwebot_official_extensions = ["data/cs_autochess/*.json"]
@@ -1,5 +1,18 @@
"""Reviewed JWeBot local command adapters and broker implementation.""" """Reviewed JWeBot local command adapters and broker implementation."""
from .cs_autochess import (
CARD_BY_ID,
COACH_BONUSES,
COACH_POOL,
MAX_PLAYERS,
MIN_PLAYERS,
PLAYER_POOL,
CsMajorAutochess,
build_round_plan,
lineup_power,
matchup_modifier,
parse_autochess_action,
)
from .descriptors import ( from .descriptors import (
DIRECT_KERNEL_COMMANDS, DIRECT_KERNEL_COMMANDS,
DIRECT_KERNEL_COMMAND_KINDS, DIRECT_KERNEL_COMMAND_KINDS,
@@ -20,6 +33,8 @@ from .local_commands import (
LocalCommandError, LocalCommandError,
deterministic_int, deterministic_int,
local_command_parts, local_command_parts,
pack_reply_parts,
unpack_reply_parts,
) )
from .runtime import ( from .runtime import (
LocalExtensionError, LocalExtensionError,
@@ -35,6 +50,10 @@ from .steam import (
) )
__all__ = [ __all__ = [
"CARD_BY_ID",
"COACH_BONUSES",
"COACH_POOL",
"CsMajorAutochess",
"DICE_CHALLENGE_TIMEOUT_SECONDS", "DICE_CHALLENGE_TIMEOUT_SECONDS",
"DEFAULT_STEAM_MIHOMO_PROXY_URL", "DEFAULT_STEAM_MIHOMO_PROXY_URL",
"DIRECT_KERNEL_COMMANDS", "DIRECT_KERNEL_COMMANDS",
@@ -43,8 +62,12 @@ __all__ = [
"LOCAL_COMMAND_ALIASES", "LOCAL_COMMAND_ALIASES",
"LOCAL_EXTENSIONS", "LOCAL_EXTENSIONS",
"LOCAL_GAME_TIMEOUT_SECONDS", "LOCAL_GAME_TIMEOUT_SECONDS",
"MAX_PLAYERS",
"MAX_RPS_PLAYERS", "MAX_RPS_PLAYERS",
"MIN_PLAYERS",
"OFFICIAL_AUTHOR", "OFFICIAL_AUTHOR",
"PLAYER_POOL",
"build_round_plan",
"KernelCommandDescriptor", "KernelCommandDescriptor",
"LocalExtensionDescriptor", "LocalExtensionDescriptor",
"LocalExtensionError", "LocalExtensionError",
@@ -58,8 +81,13 @@ __all__ = [
"deterministic_int", "deterministic_int",
"local_command_parts", "local_command_parts",
"local_extension_command_token", "local_extension_command_token",
"lineup_power",
"matchup_modifier",
"pack_reply_parts",
"parse_autochess_action",
"unpack_reply_parts",
"extension_for_kind", "extension_for_kind",
"render_feature_list", "render_feature_list",
] ]
__version__ = "0.6.3a0+1015" __version__ = "1.0.0b1+1016"
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-loan."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-loan'
AUTHOR = 'jwebot'
DESCRIPTION = '贷款'
HELP_ENTRIES = ('/贷款;/还款',)
COMMANDS = ('/贷款', '/借款', '/loan', '/borrow', '/还款', '/repay')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-ranking."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-ranking'
AUTHOR = 'jwebot'
DESCRIPTION = '富榜'
HELP_ENTRIES = ('/大富翁榜',)
COMMANDS = ('/金币排行', '/金币排行榜', '/大富翁榜', '/richlist')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-transfer."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-transfer'
AUTHOR = 'jwebot'
DESCRIPTION = '金币转账'
HELP_ENTRIES = ('/转账 对方 金额;/transfer',)
COMMANDS = ('/转账', '/transfer')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for cs-major-autochess."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'cs-major-autochess'
AUTHOR = 'jwebot'
DESCRIPTION = '自走棋'
HELP_ENTRIES = ('/自走棋 开始|报名|选',)
COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -5,8 +5,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games' PROFILE = 'single-player-games'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点' DESCRIPTION = '金币游戏'
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games') HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
"event": event.to_mapping(), "event": event.to_mapping(),
}, },
) )
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text") text = response.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text") raise ValueError("local service returned invalid text")
@@ -0,0 +1,185 @@
{
"coaches": [
{
"bonus": 9,
"clutch": 88,
"firepower": 78,
"id": "zonic",
"level": 5,
"name": "zonic",
"stability": 92,
"style": "冠军体系"
},
{
"bonus": 9,
"clutch": 84,
"firepower": 72,
"id": "blade",
"level": 5,
"name": "B1ad3",
"stability": 96,
"style": "结构控图"
},
{
"bonus": 8,
"clutch": 80,
"firepower": 86,
"id": "xtqzzz",
"level": 4,
"name": "XTQZZZ",
"stability": 78,
"style": "进攻提速"
},
{
"bonus": 8,
"clutch": 90,
"firepower": 76,
"id": "neo",
"level": 4,
"name": "NEO",
"stability": 82,
"style": "残局纪律"
},
{
"bonus": 7,
"clutch": 82,
"firepower": 80,
"id": "saw",
"level": 3,
"name": "sAw",
"stability": 80,
"style": "临场调整"
},
{
"bonus": 7,
"clutch": 78,
"firepower": 74,
"id": "robban",
"level": 3,
"name": "RobbaN",
"stability": 86,
"style": "更衣室管理"
},
{
"bonus": 6,
"clutch": 76,
"firepower": 84,
"id": "kane",
"level": 2,
"name": "kane",
"stability": 72,
"style": "明星激活"
},
{
"bonus": 6,
"clutch": 74,
"firepower": 70,
"id": "trace",
"level": 2,
"name": "trace",
"stability": 86,
"style": "防守复盘"
},
{
"bonus": 5,
"clutch": 72,
"firepower": 76,
"id": "ash",
"level": 1,
"name": "ash",
"stability": 74,
"style": "新人培养"
},
{
"bonus": 5,
"clutch": 70,
"firepower": 70,
"id": "mithr",
"level": 1,
"name": "mithR",
"stability": 78,
"style": "基础纪律"
},
{
"bonus": 8,
"clutch": 82,
"firepower": 74,
"id": "kuben",
"level": 4,
"name": "kuben",
"stability": 88,
"style": "慢控默认"
},
{
"bonus": 7,
"clutch": 78,
"firepower": 72,
"id": "groove",
"level": 3,
"name": "groove",
"stability": 84,
"style": "学院体系"
},
{
"bonus": 8,
"clutch": 80,
"firepower": 86,
"id": "guerri",
"level": 4,
"name": "guerri",
"stability": 76,
"style": "巴西爆弹"
},
{
"bonus": 8,
"clutch": 82,
"firepower": 76,
"id": "xizt",
"level": 4,
"name": "Xizt",
"stability": 86,
"style": "默认纪律"
},
{
"bonus": 7,
"clutch": 78,
"firepower": 80,
"id": "sycrone",
"level": 3,
"name": "sycrone",
"stability": 80,
"style": "青年提拔"
},
{
"bonus": 7,
"clutch": 80,
"firepower": 82,
"id": "malek",
"level": 3,
"name": "maLeK",
"stability": 78,
"style": "明星平衡"
},
{
"bonus": 6,
"clutch": 76,
"firepower": 78,
"id": "peacemaker",
"level": 2,
"name": "peacemaker",
"stability": 74,
"style": "临时救火"
},
{
"bonus": 6,
"clutch": 74,
"firepower": 72,
"id": "kassad",
"level": 2,
"name": "kassad",
"stability": 82,
"style": "纪律重建"
}
],
"schema_version": 1
}
@@ -0,0 +1,163 @@
{
"fragments": {
"challenges": [
"媒体日问题很尖锐,队伍需要在镜头前保持状态。",
"赞助商临时加码,要求队伍用指定战术打出观赏性。",
"主办方安排了签名会,状态管理成了额外考验。",
"训练馆网络短暂波动,队伍必须快速适应备用机器。",
"赛前采访拖长,指挥需要把注意力重新拉回比赛。",
"赞助商想看激进打法,但稳住节奏的人会拿到更高回报。",
"媒体日不是战斗回合,却会影响队伍的运营资源。",
"队伍如果阵容完整,就更容易把这次插曲处理成奖金。",
"这次挑战只出现一次,处理完就回到小组赛主线。",
"主舞台彩排占用了热身时间,教练的管理能力开始生效。",
"赞助商要求展示新战术,选错方向也不会淘汰,只会少拿金币。",
"现场采访提到了前一场失利,队员心态需要稳住。",
"这类挑战不扣生命,目的只是给运营阶段增加变数。",
"处理好这次插曲的队伍,会在下一场前多一点经济余地。"
],
"closers": [
"{team} 没有给翻盘窗口,最后一波回防处理得很完整。",
"{team} 在赛点局选择最熟悉的默认,用纪律结束地图。",
"{team} 最后一回合没有贪枪,稳稳把人数优势转成胜势。",
"{team} 的收官很干净,地图尾声没有出现多余失误。",
"{team} 用一波双拉打开包点,最后比分定格。",
"{team} 把比赛拖进自己舒服的节奏,收尾阶段非常稳。",
"{team} 赛点局的道具层次很清楚,对手没有舒服的回防路线。",
"{team} 终于等到对手前压,抓住这个窗口终结地图。",
"{team} 收下最后一分时没有冒进,这就是强队式结尾。",
"{team} 最后一枪来得很快,但前面的信息铺垫已经足够。",
"{team} 在关键经济局之后顺势收掉地图。",
"{team} 先控退路再进包点,结尾没有留下残局悬念。",
"{team} 把上半场积累的优势守到最后。",
"{team} 用一波慢夹收官,比分看起来合理,过程更像真实比赛。",
"{team} 没让对手连追,赛点局直接把节奏抢回来。",
"{team} 的最后一分赢在补枪,枪线没有断。",
"{team} 收官回合把每个角落都清干净,耐心给了回报。",
"{team} 把悬念留到最后一刻,但关键枪没有手软。"
],
"clutches": [
"{player} 留到最后一个残局,靠一颗烟把拆包时间偷了出来。",
"{player} 没有急着补枪,等脚步声靠近后才横拉收掉残局。",
"{player} 在 1v2 里先断退路,再回头处理包点。",
"{player} 的残局处理很冷,假拆骗出第一枪后直接反清。",
"{player} 被逼到死角,但一颗反弹闪把局面重新打开。",
"{player} 没有追击残血,而是先保住雷包,判断非常老练。",
"{player} 关键回合双杀,把本来要崩的经济线救回来。",
"{player} 在读秒阶段完成补枪,队友的拉扯给得刚刚好。",
"{player} 先拿信息再开枪,这个残局赢在耐心。",
"{player} 硬吃白光完成换人,比分没有被对手咬住。",
"{player} 从连接位摸出,正好打掉回防节奏。",
"{player} 这次不追求集锦,只把枪线封死等队友到位。",
"{player} 的狙击枪没有空枪,关键首杀让整回合变得简单。",
"{player} 残血仍然敢看第一枪位,队伍因此拿到赛点。",
"{player} 拿下残局后没有庆祝,马上提醒队友保枪经济。",
"{player} 靠脚步假信息骗出转点,最后一枪很稳。",
"{player} 在包点里连续换位,对手始终没能定位。",
"{player} 把闪光背身吃完后仍然补到第二枪,这是关键转折。",
"{player} 一个人拖住整条回防路线,为队伍争到拆包窗口。",
"{player} 最后选择不 peek,让时间替自己赢下这一分。"
],
"economy": [
"{team} 赢下这一分后可以完整起枪,下一回合选择会更多。",
"{team} 输掉长枪局后经济见底,可能要交出一回合调整空间。",
"{team} 没有强行追梦,选择保枪把下一回合留住。",
"{team} 的连败补偿到位,下一回合有机会靠道具质量反打。",
"{team} 这回合缴下两把枪,经济收益比比分更重要。",
"{team} 虽然丢分,但保下主狙,下一回合还有破局点。",
"{team} 的半甲手枪没有打成,但至少逼出了对手不少道具。",
"{team} 经济优势开始显现,可以更大胆地争夺第一枪位。",
"{team} 如果下一分再丢,就必须进入半起调整。",
"{team} 这一波选择全甲全弹,明显是要在中期一次打穿。",
"{team} 保下来的两把枪让下回合不至于完全被动。",
"{team} 连续换枪成功,把对手的存款一点点磨掉。",
"{team} 用低成本回合换到下包,经济线还没有断。",
"{team} 这波赢的不只是分数,还有下一回合的主动权。",
"{team} 被重置经济后节奏放慢,先求稳住半场尾声。",
"{team} 靠残局胜利拿到额外空间,可以考虑继续 D 牌补强。"
],
"openings": [
"{team} 开局没有急着提速,先把默认控图铺开。",
"{team} 第一波提速很坚决,外场道具落点干净。",
"{team} 在手枪局后选择慢摸,试图逼出对手的前压信息。",
"{team} 的狙击手早早架住第一交火点,节奏被压得很低。",
"{team} 连续用两颗闪光清掉近点,进攻面一下打开。",
"{team} 防守开局站位很深,把回防路线留得很完整。",
"{team} 先拿地图控制权,再把包点压力一点点推上去。",
"{team} 开局没有赌点,靠信息互换慢慢读阵型。",
"{team} 在第一枪位赢下后没有贪,马上回收人数优势。",
"{team} 用一波假打骗出轮转,第二时间才开始真正爆弹。",
"{team} 选择保守开局,宁愿少拿信息也不送首杀。",
"{team} 的指挥把默认拉得很散,让对手很难判断重心。",
"{team} 手枪局道具很少,但站位互补做得很扎实。",
"{team} 没有被前几回合影响,依旧按自己的节奏展开。",
"{team} 的第一套进攻明显针对了对手弱侧。",
"{team} 在经济局也保留了两颗关键闪,准备打后点反清。",
"{team} 上来就抢中路,想用主动权换后续转点空间。",
"{team} 先把狙击枪放在安全位,围绕它慢慢组织推进。"
],
"pauses": [
"比分被拉开后,双方都慢了半拍,像是在重新读这张图。",
"这里适合停一下,刚才几回合的经济差已经开始影响下一枪。",
"暂停回来之后,防守方明显把第一接触点后撤了半格。",
"连续交火后节奏突然变慢,双方都在等对方先犯错。",
"这一段没有夸张集锦,但每一颗道具都在改变下一回合。",
"场面安静了几秒,真正决定胜负的是下一次首杀。",
"队伍语音应该都在压节奏,没人愿意在这里送掉经济。",
"这不是简单的比分变化,战术重心已经从爆弹转到控图。",
"现场进入短暂拉扯,地图控制权一点点被重新分配。",
"两边都知道下一分很贵,所以这回合开局格外谨慎。",
"这一小段空白很重要,强队往往就是在这里调整默认。",
"比赛没有马上进入下一波交火,指挥正在重新排优先级。",
"刚才的连分让对手不得不换站位,防线开始出现试探。",
"经济压力让双方都少了一点容错,下一波道具会很关键。",
"这里没有急躁的前压,只有一点点互相试探。",
"暂停不是为了拖时间,而是在给下一套进攻换答案。",
"双方都把节奏压住,地图像被重新归零。",
"这一分之前的几秒钟很长,所有人都在等第一个脚步声。",
"场上没有无意义的 peek,每个人都在守自己的触发条件。",
"这段过渡让比赛更像真正的 BO 系列赛,而不是单纯掷骰子。"
],
"runs": [
"{team} 连拿 {count} 分之后,现场短暂安静下来。",
"{team} 把经济滚起来后,连续 {count} 个回合没有给对手完整开枪空间。",
"{team} 这一波 {count} 连分不是纯枪法碾压,而是每回合都换到正确信息。",
"{team} 的节奏忽快忽慢,{count} 个回合里每次进点方式都不一样。",
"{team} 靠补枪链吃下 {count} 分,队伍语音应该已经很热。",
"{team} 的中期处理开始成型,连续 {count} 分把对手钱打空。",
"{team} 没有急着结束比赛,而是用 {count} 连分把地图控制牢牢握住。",
"{team} 这段 {count} 连分很关键,尤其是第二个残局稳定了军心。",
"{team} 在暂停之后突然提速,直接打出 {count} 连分。",
"{team} 先抓前压,再抓回防,{count} 个回合把读秒处理做满。",
"{team} 的防守交叉火力越站越舒服,{count} 连分让比分拉开。",
"{team} 把对手的道具节奏读透,连续 {count} 分都避开了主防区。",
"{team} 经济起飞后没有乱买,{count} 连分中枪线一直保持完整。",
"{team} 在被首杀的回合仍然能翻,{count} 连分含金量很高。",
"{team} 的默认推进开始收利息,{count} 个回合每次都能换到便宜。",
"{team} 靠一波小连胜把压力交回去,{count} 分之后对手必须叫停。",
"{team} 的二楼夹击反复奏效,{count} 连分让对手开始犹豫。",
"{team} 连续 {count} 回合把残局拖到优势人数,后点纪律很稳。",
"{team} 打出 {count} 连分后节奏放慢,像是在等对手自己露破绽。",
"{team} 这波 {count} 连分让地图气质变了,所有枪位都开始往前压。"
],
"training_hooks": [
"训练赛不计入小组胜负,但会发金币,方便后续刷新商店。",
"这轮训练更像实战热身,每队都会遇到同场参赛对手。",
"训练赛重点不是淘汰谁,而是检验当前阵容能不能打完整张图。",
"这轮结束后可以 D 牌、锁店或调首发,下一轮再检验成果。",
"AI 战队也会参与训练,对局结果会写入档案。",
"如果阵容缺少指挥,训练赛的中期回合会更容易失控。",
"如果你想追羁绊,现在是最适合刷新商店的窗口。",
"训练赛的金币很重要,别把每次刷新都当成免费试错。",
"这一阶段没有生命扣除,重点是运营和阵容成型。",
"训练赛的地图会轮换,不会一直用同一个固定比分模板。",
"每轮训练结束后,真人都需要发送 /结束回合 才会进入下一轮。",
"训练赛会按轮次播报,避免一次性刷出一大串结果。",
"如果出现轮空,会安排本地陪练队补一张 BO1。",
"训练阶段适合观察战术克制,火力、稳定、残局都会影响回合走向。",
"连胜会带来金币,但失败也有保底,保证测试局能继续运营。",
"这部分是纯文字自走棋的节奏层,不再是单条目标数值判定。"
]
},
"schema_version": 1
}
@@ -0,0 +1,309 @@
{
"ai": {
"power_multiplier": 0.82,
"primary_display_name": "AI 战队",
"primary_person_key": "__local_ai_team__",
"secondary_display_name": "AI 战队 B",
"secondary_person_key": "__local_ai_team_2__"
},
"battle": {
"damage": {
"bo5_sweep": 18,
"non_sweep": 10,
"sweep": 15
},
"maps": [
"Mirage",
"Inferno",
"Nuke",
"Ancient",
"Dust2",
"Anubis",
"Overpass"
],
"match_best_of": {
"final": 5,
"group": 3,
"semifinal": 5,
"training": 1
},
"mr_rounds": 30,
"overtime_safety_cap": 72,
"overtime_segment_rounds": 6,
"overtime_start_score": 15,
"overtime_target_score": 19,
"overtime_win_by": 2,
"round_probability": {
"base": 0.5,
"first_half_side_swing": 0.018,
"jitter_center": 350,
"jitter_divisor": 10000,
"jitter_mod": 701,
"max": 0.68,
"min": 0.32,
"power_edge_cap": 0.16,
"power_edge_scale": 0.3,
"pressure_cap": 0.04,
"pressure_scale": 0.004,
"second_half_side_swing": -0.012
},
"score_bands": [
{
"label": "压制",
"max_loser_score": 6
},
{
"label": "优势",
"max_loser_score": 10
},
{
"label": "小胜",
"max_loser_score": 13
},
{
"label": "险胜",
"max_loser_score": 999
}
],
"target_score": 16
},
"coach": {
"fallback_bonuses": {
"1": 2,
"2": 4,
"3": 6,
"4": 8,
"5": 10
},
"upgrade_costs": {
"1": 6,
"2": 10,
"3": 16,
"4": 24
}
},
"draft": {
"coach_choice_count": 10,
"coach_round": 1,
"later_round_preferred_costs": [
3,
2,
4,
1,
5
],
"player_choice_count": 6,
"player_copy_count": 3,
"round_two_preferred_costs": [
2,
3,
1,
4,
5
],
"rounds": 3
},
"economy": {
"round_income": {
"base": 5,
"interest_cap": 5,
"interest_divisor": 10
},
"series_rewards": {
"group_win_gold": 5,
"playoff_win_gold": 6
},
"starting_gold": 10,
"starting_hp": 100,
"training_rewards": {
"bye_gold": 2,
"loss_gold": 1,
"win_gold": 3
}
},
"pve_events": {
"pve_media": {
"loss_gold": 3,
"name": "媒体日挑战",
"target": 335,
"win_gold": 6
},
"pve_sponsor": {
"loss_gold": 4,
"name": "赞助挑战",
"target": 395,
"win_gold": 8
},
"pve_training": {
"loss_gold": 2,
"name": "训练赛",
"target": 275,
"win_gold": 4
}
},
"roster": {
"bench_limit": 3,
"nation_synergy": {
"max_sources": 1,
"thresholds": [
{
"bonus": 18,
"min_count": 5
},
{
"bonus": 12,
"min_count": 4
},
{
"bonus": 7,
"min_count": 3
},
{
"bonus": 3,
"min_count": 2
}
]
},
"star_multipliers": {
"1": 1.0,
"2": 1.35,
"3": 1.8
},
"starter_count": 5,
"team_synergy": {
"max_sources": 2,
"thresholds": [
{
"bonus": 22,
"min_count": 5
},
{
"bonus": 15,
"min_count": 4
},
{
"bonus": 9,
"min_count": 3
},
{
"bonus": 4,
"min_count": 2
}
]
},
"unit_power_weights": {
"clutch": 0.25,
"firepower": 0.45,
"leadership_scale": 0.12,
"leadership_threshold": 70,
"stability": 0.3
}
},
"schema_version": 1,
"shop": {
"cost_pool_copies": {
"1": 18,
"2": 15,
"3": 12,
"4": 9,
"5": 7
},
"odds": {
"1": {
"1": 70,
"2": 25,
"3": 5,
"4": 0,
"5": 0
},
"2": {
"1": 50,
"2": 30,
"3": 18,
"4": 2,
"5": 0
},
"3": {
"1": 30,
"2": 35,
"3": 25,
"4": 9,
"5": 1
},
"4": {
"1": 15,
"2": 30,
"3": 30,
"4": 20,
"5": 5
},
"5": {
"1": 5,
"2": 20,
"3": 30,
"4": 30,
"5": 15
}
},
"refresh_cost": 2,
"size": 6
},
"special_events": {
"media": {
"loss_gold": 1,
"name": "媒体日挑战",
"target": 320,
"win_gold": 4
},
"sponsor": {
"loss_gold": 1,
"name": "赞助挑战",
"target": 340,
"win_gold": 4
}
},
"tactics": {
"attribute_counters": [
[
"稳定",
"火力"
],
[
"火力",
"残局"
],
[
"残局",
"稳定"
]
],
"axis_percent_cap": 8.0,
"axis_percent_divisor": 12.5,
"counters": {
"残局": "稳定",
"火力": "残局",
"稳定": "火力"
},
"names": [
"火力",
"稳定",
"残局"
],
"role_counters": {
"指挥": "狙击手",
"步枪手": "指挥",
"狙击手": "步枪手"
},
"role_percent_cap": 5.0,
"role_percent_scale": 0.8,
"selected_axis_multiplier": 1.08,
"tactic_counter_percent": 6.0,
"total_percent_cap": 15.0
},
"tournament": {
"event_title_template": "第 {season} 届 IEM 盐田 Major",
"host_name": "Taffy",
"max_players": 8,
"min_players": 2
}
}
@@ -42,6 +42,18 @@ LOCAL_COMMAND_ALIASES = {
"context": "context_summary", "context": "context_summary",
"金币": "wallet", "金币": "wallet",
"wallet": "wallet", "wallet": "wallet",
"金币排行": "coin_ranking",
"金币排行榜": "coin_ranking",
"大富翁榜": "coin_ranking",
"richlist": "coin_ranking",
"转账": "transfer",
"transfer": "transfer",
"贷款": "loan",
"借款": "loan",
"loan": "loan",
"borrow": "loan",
"还款": "repay",
"repay": "repay",
"抽奖": "prize_draw", "抽奖": "prize_draw",
"draw": "prize_draw", "draw": "prize_draw",
"彩票": "lottery", "彩票": "lottery",
@@ -61,6 +73,20 @@ LOCAL_COMMAND_ALIASES = {
"人物": "person_info", "人物": "person_info",
"个人属性": "person_info", "个人属性": "person_info",
"person": "person_info", "person": "person_info",
"自走棋": "cs_autochess",
"cs自走棋": "cs_autochess",
"autochess": "cs_autochess",
"选": "cs_autochess",
"刷新": "cs_autochess",
"锁定": "cs_autochess",
"战术": "cs_autochess",
"升级教练": "cs_autochess",
"上阵": "cs_autochess",
"下阵": "cs_autochess",
"出售": "cs_autochess",
"阵容": "cs_autochess",
"赛程": "cs_autochess",
"结束回合": "cs_autochess",
} }
@@ -124,12 +150,13 @@ LOCAL_EXTENSIONS = (
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="single-player-games", extension_id="single-player-games",
author=JWEBOT_AUTHOR, author=JWEBOT_AUTHOR,
description="虚拟金币账户、抽奖、群内多人彩票和单人21点", description="金币游戏",
help_entries=( help_entries=(
"/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory", "/金币 /wallet;/游戏记录 /gamehistory",
"/彩票 开始|报名|当前状态|开奖|取消;/lottery", "/抽奖 [金额];/draw",
"/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)", "/彩票 开始|报名|状态|开奖|取消;/lottery",
"/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", "/21点 开始 [1~500];/blackjack start",
"/21点 要牌|停牌|状态|结束;/blackjack",
"/游戏帮助;/games", "/游戏帮助;/games",
), ),
command_kinds=( command_kinds=(
@@ -141,6 +168,36 @@ LOCAL_EXTENSIONS = (
"game_rules", "game_rules",
), ),
), ),
LocalExtensionDescriptor(
extension_id="coin-transfer",
author=JWEBOT_AUTHOR,
description="金币转账",
help_entries=("/转账 对方 金额;/transfer",),
command_kinds=("transfer",),
),
LocalExtensionDescriptor(
extension_id="coin-ranking",
author=JWEBOT_AUTHOR,
description="富榜",
help_entries=("/大富翁榜",),
command_kinds=("coin_ranking",),
),
LocalExtensionDescriptor(
extension_id="coin-loan",
author=JWEBOT_AUTHOR,
description="贷款",
help_entries=("/贷款;/还款",),
command_kinds=("loan", "repay"),
),
LocalExtensionDescriptor(
extension_id="cs-major-autochess",
author=JWEBOT_AUTHOR,
description="自走棋",
help_entries=(
"/自走棋 开始|报名|选",
),
command_kinds=("cs_autochess",),
),
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="person-directory", extension_id="person-directory",
author=OFFICIAL_AUTHOR, author=OFFICIAL_AUTHOR,
@@ -11,9 +11,11 @@ import subprocess
import time import time
from collections import Counter from collections import Counter
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from .cs_autochess import CsMajorAutochess
from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list
from .player_games import PlayerGames from .player_games import PlayerGames
@@ -24,6 +26,8 @@ MAX_RPS_PLAYERS = 8
BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai") BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai")
DICE_PATTERN = re.compile(r"^(?:(\d{1,2})[dD](\d{1,3}))?$") DICE_PATTERN = re.compile(r"^(?:(\d{1,2})[dD](\d{1,3}))?$")
RANDOM_NUMBER_PATTERN = re.compile(r"^(-?\d{1,6})\s+(-?\d{1,6})$") RANDOM_NUMBER_PATTERN = re.compile(r"^(-?\d{1,6})\s+(-?\d{1,6})$")
MULTI_REPLY_MARKER = "\x1eJWEBOT_MULTI_REPLY_V1\x1e"
CHANGELOG_HEADING = re.compile(r"^##\s+(.+?)(?:\s+-\s+(\d{4}-\d{2}-\d{2}))?\s*$")
class LocalCommandError(RuntimeError): class LocalCommandError(RuntimeError):
@@ -52,6 +56,36 @@ def deterministic_int(seed: str, lower: int, upper: int) -> int:
return lower + value % (upper - lower + 1) return lower + value % (upper - lower + 1)
def pack_reply_parts(parts: list[str] | tuple[str, ...]) -> str:
cleaned = [str(part).strip() for part in parts if str(part).strip()]
if not cleaned:
raise LocalCommandError("本地命令没有可发送回复")
if len(cleaned) == 1:
return cleaned[0]
return MULTI_REPLY_MARKER + json.dumps(
cleaned,
ensure_ascii=False,
separators=(",", ":"),
)
def unpack_reply_parts(value: object) -> list[str] | None:
text = str(value or "")
if not text.startswith(MULTI_REPLY_MARKER):
return None
try:
payload = json.loads(text[len(MULTI_REPLY_MARKER) :])
except json.JSONDecodeError:
return None
if (
not isinstance(payload, list)
or not payload
or any(not isinstance(item, str) or not item.strip() for item in payload)
):
return None
return [item.strip() for item in payload]
def local_command_parts(question: str) -> tuple[str, str]: def local_command_parts(question: str) -> tuple[str, str]:
normalized = question.lstrip() normalized = question.lstrip()
if not normalized.startswith("/"): if not normalized.startswith("/"):
@@ -107,6 +141,7 @@ class LocalCommandEngine:
self.profile_fact_is_safe = profile_fact_is_safe self.profile_fact_is_safe = profile_fact_is_safe
self.profile_expiry = profile_expiry self.profile_expiry = profile_expiry
self.players = PlayerGames(ledger, person_display_label) self.players = PlayerGames(ledger, person_display_label)
self.autochess = CsMajorAutochess(ledger)
def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str: def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str:
question = str(row["question"]) question = str(row["question"])
@@ -121,6 +156,10 @@ class LocalCommandEngine:
return self.reply_cleaner(self.person_index(row, kind, arguments)) return self.reply_cleaner(self.person_index(row, kind, arguments))
if kind in { if kind in {
"wallet", "wallet",
"coin_ranking",
"transfer",
"loan",
"repay",
"prize_draw", "prize_draw",
"lottery", "lottery",
"blackjack", "blackjack",
@@ -128,6 +167,12 @@ class LocalCommandEngine:
"game_rules", "game_rules",
}: }:
return self.reply_cleaner(self.players.handle(row, kind, arguments)) return self.reply_cleaner(self.players.handle(row, kind, arguments))
if kind == "cs_autochess":
parts = [
self.reply_cleaner(part)
for part in self.autochess.handle_parts(row)
]
return pack_reply_parts(parts)
if kind == "dice": if kind == "dice":
return self.reply_cleaner(self.dice(row, arguments)) return self.reply_cleaner(self.dice(row, arguments))
if kind == "dice_challenge": if kind == "dice_challenge":
@@ -358,13 +403,75 @@ class LocalCommandEngine:
return "unknown" return "unknown"
return str(getattr(module, "__version__", "unknown")) return str(getattr(module, "__version__", "unknown"))
@staticmethod
def changelog_candidates() -> list[Path]:
candidates: list[Path] = []
module_path = Path(__file__).resolve()
for parent in (module_path.parent, *module_path.parents):
candidates.append(parent / "CHANGELOG.md")
candidates.append(Path.cwd() / "CHANGELOG.md")
seen: set[Path] = set()
unique: list[Path] = []
for candidate in candidates:
if candidate in seen:
continue
seen.add(candidate)
unique.append(candidate)
return unique
@classmethod
def latest_changelog(cls) -> dict[str, str]:
for path in cls.changelog_candidates():
if not path.is_file():
continue
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
for index, line in enumerate(lines):
match = CHANGELOG_HEADING.match(line)
if not match:
continue
version = match.group(1).strip()
updated_at = (match.group(2) or "").strip()
bullet_parts: list[str] = []
collecting = False
for item in lines[index + 1 :]:
if item.startswith("## "):
break
if item.startswith("- "):
if collecting:
break
collecting = True
bullet_parts.append(item[2:].strip())
continue
if collecting:
if item.startswith(" "):
bullet_parts.append(item.strip())
continue
if not item.strip():
break
summary = normalize_text(" ".join(bullet_parts))
return {
"version": version,
"updated_at": updated_at or "unknown",
"summary": summary or "暂无更新摘要",
}
return {
"version": "unknown",
"updated_at": "unknown",
"summary": "未找到 CHANGELOG.md",
}
def jwebot_version(self) -> str: def jwebot_version(self) -> str:
changelog = self.latest_changelog()
return ( return (
"JWeBot 版本:" "JWeBot 版本:"
f"kernel {self.package_version('jwebot_kernel')}," f"kernel {self.package_version('jwebot_kernel')},"
f"sdk {self.package_version('jwebot_sdk')}," f"sdk {self.package_version('jwebot_sdk')},"
"official " "official "
f"{self.package_version('jwebot_official_extensions')}" f"{self.package_version('jwebot_official_extensions')}\n"
f"最新版本:{changelog['version']}\n"
f"更新时间:{changelog['updated_at']}\n"
f"更新摘要:{changelog['summary']}"
) )
@staticmethod @staticmethod
@@ -16,6 +16,10 @@ MAX_LOTTERY_BET = 500
DEFAULT_LOTTERY_BET = 10 DEFAULT_LOTTERY_BET = 10
MIN_BLACKJACK_BET = 1 MIN_BLACKJACK_BET = 1
MAX_BLACKJACK_BET = 500 MAX_BLACKJACK_BET = 500
MIN_TRANSFER_AMOUNT = 1
MAX_TRANSFER_AMOUNT = 100_000
MIN_LOAN_AMOUNT = 1
MAX_LOAN_AMOUNT = 500
GAME_TIMEOUT_SECONDS = 600 GAME_TIMEOUT_SECONDS = 600
GAME_HISTORY_LIMIT = 50 GAME_HISTORY_LIMIT = 50
GAME_HISTORY_PAGE_SIZE = 5 GAME_HISTORY_PAGE_SIZE = 5
@@ -41,11 +45,28 @@ BLACKJACK_COMBINED_ACTION = re.compile(
r"^(?:开始|start)(?P<bet>\d{1,3})?" r"^(?:开始|start)(?P<bet>\d{1,3})?"
r"(?:要牌|hit|停牌|stand)$" r"(?:要牌|hit|停牌|stand)$"
) )
TRANSFER_MARKED = re.compile(
r"^(?:给|to)?\s*(?P<target>.+?)\s*"
r"(?:金额|金币|coin|coins|:|:)\s*(?P<amount>\d{1,6})$",
re.IGNORECASE,
)
def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]: def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
account.setdefault("balance", INITIAL_COINS) account.setdefault("balance", INITIAL_COINS)
account.setdefault("game", None) account.setdefault("game", None)
loan = account.get("loan")
try:
loan_principal = int(loan.get("principal", 0) or 0) if isinstance(loan, dict) else 0
except (TypeError, ValueError):
loan_principal = 0
if loan_principal <= 0:
account["loan"] = None
else:
account["loan"] = {
"principal": loan_principal,
"borrowed_at": float(loan.get("borrowed_at", time.time())),
}
account.setdefault("wins", 0) account.setdefault("wins", 0)
account.setdefault("losses", 0) account.setdefault("losses", 0)
account.setdefault("ties", 0) account.setdefault("ties", 0)
@@ -128,6 +149,10 @@ class PlayerGames:
def rules() -> str: def rules() -> str:
return ( return (
"每人首次使用获 1000 虚拟金币;/金币 查看余额;/游戏记录 查看本人记录。\n" "每人首次使用获 1000 虚拟金币;/金币 查看余额;/游戏记录 查看本人记录。\n"
"转账:/转账 对方 金额,或 /transfer name amount;"
"对方需先用 /金币 初始化账户。\n"
"贷款:/贷款 [1~500] 借入金币;/还款 [金额|全部] 归还,"
"同一时间最多一笔未还贷款。\n"
"抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n" "抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n"
"多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];" "多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];"
"/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;" "/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;"
@@ -137,7 +162,7 @@ class PlayerGames:
"(/blackjack hit|stand|status|end);" "(/blackjack hit|stand|status|end);"
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n" "每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。" "21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
"以上均含本金;虚拟金币不能充值、转账或兑换。" "以上均含本金;虚拟金币不能充值或兑换。"
) )
def handle(self, row: dict[str, Any], kind: str, arguments: str) -> str: def handle(self, row: dict[str, Any], kind: str, arguments: str) -> str:
@@ -150,6 +175,12 @@ class PlayerGames:
return self.game_history(row, arguments) return self.game_history(row, arguments)
if kind == "lottery": if kind == "lottery":
return self.group_lottery(row, arguments) return self.group_lottery(row, arguments)
if kind == "coin_ranking":
return self.coin_ranking(row, arguments)
if kind == "transfer":
return self.transfer(row, arguments)
if kind in {"loan", "repay"}:
return self.loan_or_repay(row, kind, arguments)
if kind == "wallet" and arguments: if kind == "wallet" and arguments:
return "用法:/金币(/wallet),查看本人的虚拟金币;/游戏帮助 查看规则" return "用法:/金币(/wallet),查看本人的虚拟金币;/游戏帮助 查看规则"
if kind == "prize_draw": if kind == "prize_draw":
@@ -222,9 +253,13 @@ class PlayerGames:
expired = "上一局已超时,按认输结束;" expired = "上一局已超时,按认输结束;"
if kind == "wallet": if kind == "wallet":
state = "进行中" if account.get("game") else "无进行中的牌局" state = "进行中" if account.get("game") else "无进行中的牌局"
return (f"{expired}金币 {account['balance']};21点 {state};" loan = account.get("loan")
loan_text = f"未还贷款 {loan['principal']};" if loan else "无贷款;"
return (
f"{expired}金币 {account['balance']};{loan_text}21点 {state};"
f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。" f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。"
"首次赠送1000仅发一次;/游戏帮助 查看规则") "首次赠送1000仅发一次;/游戏帮助 查看规则"
)
if kind == "blackjack" and action in {"hit", "stand", "status", "end"}: if kind == "blackjack" and action in {"hit", "stand", "status", "end"}:
active = account.get("game") active = account.get("game")
index = int(active["index"]) if active else None index = int(active["index"]) if active else None
@@ -283,6 +318,180 @@ class PlayerGames:
str(row["group_fingerprint"]), str(row["sender"]), str(row["group_fingerprint"]), str(row["sender"]),
str(row["event_id"]), kind + ":" + arguments, execute) str(row["event_id"]), kind + ":" + arguments, execute)
def coin_ranking(self, row: dict[str, Any], arguments: str) -> str:
value = arguments.strip()
if value and not re.fullmatch(r"\d{1,2}", value):
return "用法:/大富翁榜 或 /金币排行 [人数]"
limit = int(value or 10)
if not 1 <= limit <= 20:
return "金币排行人数必须为 1~20"
records = self.ledger.player_coin_ranking(
str(row["group_fingerprint"]),
limit=limit,
)
if not records:
return "当前群还没有金币账户;发送 /金币 初始化"
lines = ["大富翁榜:"]
for index, record in enumerate(records, start=1):
loan = int(record.get("loan", 0))
loan_text = f"(未还贷款 {loan})" if loan else ""
lines.append(
f"{index}. {record['display_name']} {record['balance']} 金币"
f"{loan_text}"
)
return "\n".join(lines)
@staticmethod
def parse_transfer(
arguments: str,
) -> tuple[str, int] | None:
value = arguments.strip()
if not value:
return None
marked = TRANSFER_MARKED.fullmatch(value)
if marked is not None:
target = marked["target"]
amount = int(marked["amount"])
else:
tokens = value.split()
if len(tokens) < 2:
return None
if re.fullmatch(r"\d{1,6}", tokens[0]):
amount = int(tokens[0])
target = " ".join(tokens[1:])
elif re.fullmatch(r"\d{1,6}", tokens[-1]):
amount = int(tokens[-1])
target = " ".join(tokens[:-1])
else:
return None
target = re.sub(r"^(?:给|to)\s*", "", target.strip(), flags=re.IGNORECASE)
target = target.lstrip("@@").strip()
if not target:
return None
return target, amount
def transfer(self, row: dict[str, Any], arguments: str) -> str:
parsed = self.parse_transfer(arguments)
if parsed is None:
return (
"用法:/转账 对方 金额,例如 /转账 Bob 100;"
"英文 /transfer Bob 100。金额范围 1~100000,"
"对方需先发送 /金币 初始化账户"
)
target, amount = parsed
if not MIN_TRANSFER_AMOUNT <= amount <= MAX_TRANSFER_AMOUNT:
return "转账金额必须为 1~100000 的整数金币"
def render(data: dict[str, Any]) -> str:
source = data["source_display_name"]
target_name = data["target_display_name"]
if data["status"] == "self":
return f"{source}:不能给自己转账"
if data["status"] == "target_missing":
return (
f"{source}:{target_name} 还没有金币账户,"
"请先让对方发送 /金币 初始化"
)
if data["status"] == "insufficient":
return (
f"{source}:金币不足,余额 {data['source_balance']},"
f"本次需要 {data['amount']}"
)
return (
f"{source}:已向 {target_name} 转账 {data['amount']} 金币;"
f"本人金币 {data['source_balance']};"
f"{target_name} 金币 {data['target_balance']}"
)
return self.ledger.transfer_player_coins(
str(row["group_fingerprint"]),
str(row["sender"]),
str(row["event_id"]),
"transfer:" + arguments,
target,
amount,
render,
)
def loan_or_repay(self, row: dict[str, Any], kind: str, arguments: str) -> str:
value = re.sub(r"\s+", "", arguments.casefold())
if kind == "loan":
if not re.fullmatch(r"\d{1,3}", value):
return "用法:/贷款 [1~500];同一时间最多一笔未还贷款"
amount = int(value)
if not MIN_LOAN_AMOUNT <= amount <= MAX_LOAN_AMOUNT:
return "贷款金额必须为 1~500 的整数金币"
else:
amount = 0
if value not in {"", "全部", "all"}:
if not re.fullmatch(r"\d{1,6}", value):
return "用法:/还款 [金额|全部]"
amount = int(value)
if amount <= 0:
return "还款金额必须为正整数金币"
group = str(row["group_fingerprint"])
sender = str(row["sender"])
person = self.ledger.resolve_person(group, sender)
player_label = self.display_label(person["display_name"])
def execute(account: dict[str, Any]) -> str:
normalize_player_account(account)
loan = account.get("loan")
if kind == "loan":
if loan:
return (
f"{player_label}:已有未还贷款 {loan['principal']} 金币;"
"请先 /还款 [金额|全部]"
)
account["balance"] += amount
account["loan"] = {
"principal": amount,
"borrowed_at": time.time(),
}
return (
f"{player_label}:贷款 {amount} 金币已到账;"
f"当前金币 {account['balance']};未还贷款 {amount}"
)
if not loan:
return f"{player_label}:当前没有未还贷款"
debt = int(loan["principal"])
if amount <= 0:
pay = min(int(account["balance"]), debt)
else:
pay = min(amount, debt)
if pay <= 0:
return f"{player_label}:金币不足,无法还款;未还贷款 {debt}"
if pay > int(account["balance"]):
return (
f"{player_label}:金币不足,余额 {account['balance']},"
f"本次还款需要 {pay}"
)
account["balance"] -= pay
debt -= pay
if debt:
account["loan"] = {
"principal": debt,
"borrowed_at": float(loan.get("borrowed_at", time.time())),
}
return (
f"{player_label}:已还款 {pay} 金币;"
f"剩余贷款 {debt};当前金币 {account['balance']}"
)
account["loan"] = None
return (
f"{player_label}:已还清贷款 {pay} 金币;"
f"当前金币 {account['balance']}"
)
return self.ledger.apply_player_action(
group,
sender,
str(row["event_id"]),
f"{kind}:{arguments}",
execute,
)
@staticmethod @staticmethod
def parse_lottery_entry( def parse_lottery_entry(
arguments: str, arguments: str,
@@ -14,6 +14,8 @@ from .local_commands import (
LocalCommandEngine, LocalCommandEngine,
local_command_parts, local_command_parts,
utc_now, utc_now,
pack_reply_parts,
unpack_reply_parts,
) )
@@ -74,7 +76,11 @@ class LocalServiceBroker:
"question": f"{command} {arguments}".rstrip(), "question": f"{command} {arguments}".rstrip(),
"created_at": str(event.get("observed_at") or utc_now()), "created_at": str(event.get("observed_at") or utc_now()),
} }
return {"text": self.engine.handle(row, event)} text = self.engine.handle(row, event)
parts = unpack_reply_parts(text)
if parts is not None:
return {"text": parts[0], "texts": parts}
return {"text": text}
class _Context: class _Context:
@@ -162,6 +168,8 @@ class LocalExtensionRuntime:
result = handler(sdk_event, arguments) result = handler(sdk_event, arguments)
if not isinstance(result, ExtensionResult): if not isinstance(result, ExtensionResult):
raise LocalExtensionError("local adapter returned invalid result") raise LocalExtensionError("local adapter returned invalid result")
if result.reply.kind is PlanKind.MULTI_TEXT:
return pack_reply_parts(list(result.reply.messages))
if result.reply.kind is not PlanKind.TEXT: if result.reply.kind is not PlanKind.TEXT:
raise LocalExtensionError("local command returned no visible reply") raise LocalExtensionError("local command returned no visible reply")
return result.reply.message return result.reply.message
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-sdk" name = "jwebot-sdk"
version = "0.6.3a0+1015" version = "1.0.0b1+1016"
description = "Typed, capability-limited extension API for JWeBot plugins" description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [] dependencies = []
@@ -38,4 +38,4 @@ __all__ = [
"SingleGroupContext", "SingleGroupContext",
] ]
__version__ = "0.6.3a0+1015" __version__ = "1.0.0b1+1016"
@@ -20,6 +20,7 @@ class ContractViolation(ValueError):
class PlanKind(str, Enum): class PlanKind(str, Enum):
TEXT = "text" TEXT = "text"
MULTI_TEXT = "multi_text"
NO_REPLY = "no_reply" NO_REPLY = "no_reply"
@@ -156,6 +157,7 @@ class ReplyPlan:
kind: PlanKind kind: PlanKind
message: str = "" message: str = ""
messages: tuple[str, ...] = ()
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.kind is PlanKind.TEXT: if self.kind is PlanKind.TEXT:
@@ -168,13 +170,40 @@ class ReplyPlan:
limit=MAX_REPLY_CHARS, limit=MAX_REPLY_CHARS,
), ),
) )
if self.messages:
raise ContractViolation("text plan cannot contain multiple messages")
elif self.kind is PlanKind.MULTI_TEXT:
if self.message:
raise ContractViolation("multi_text plan cannot contain text")
if not self.messages:
raise ContractViolation("multi_text plan requires messages")
if len(self.messages) > 8:
raise ContractViolation("multi_text plan exceeds 8 messages")
object.__setattr__(
self,
"messages",
tuple(
_required_text(
message,
name="reply message",
limit=MAX_REPLY_CHARS,
)
for message in self.messages
),
)
elif self.message: elif self.message:
raise ContractViolation("no_reply plan cannot contain text") raise ContractViolation("no_reply plan cannot contain text")
elif self.messages:
raise ContractViolation("no_reply plan cannot contain messages")
@classmethod @classmethod
def text(cls, message: str) -> "ReplyPlan": def text(cls, message: str) -> "ReplyPlan":
return cls(kind=PlanKind.TEXT, message=message) return cls(kind=PlanKind.TEXT, message=message)
@classmethod
def multi_text(cls, messages: tuple[str, ...] | list[str]) -> "ReplyPlan":
return cls(kind=PlanKind.MULTI_TEXT, messages=tuple(messages))
@classmethod @classmethod
def no_reply(cls) -> "ReplyPlan": def no_reply(cls) -> "ReplyPlan":
return cls(kind=PlanKind.NO_REPLY) return cls(kind=PlanKind.NO_REPLY)
@@ -189,14 +218,23 @@ class ReplyPlan:
raise ContractViolation("reply plan kind is invalid") from error raise ContractViolation("reply plan kind is invalid") from error
if kind is PlanKind.TEXT: if kind is PlanKind.TEXT:
return cls.text(str(value.get("text", ""))) return cls.text(str(value.get("text", "")))
if kind is PlanKind.MULTI_TEXT:
messages = value.get("texts", ())
if not isinstance(messages, list):
raise ContractViolation("multi_text plan messages must be a list")
return cls.multi_text([str(message) for message in messages])
if value.get("text"): if value.get("text"):
raise ContractViolation("no_reply plan cannot contain text") raise ContractViolation("no_reply plan cannot contain text")
if value.get("texts"):
raise ContractViolation("no_reply plan cannot contain messages")
return cls.no_reply() return cls.no_reply()
def to_mapping(self) -> dict[str, str]: def to_mapping(self) -> dict[str, Any]:
value = {"kind": self.kind.value} value = {"kind": self.kind.value}
if self.kind is PlanKind.TEXT: if self.kind is PlanKind.TEXT:
value["text"] = self.message value["text"] = self.message
if self.kind is PlanKind.MULTI_TEXT:
value["texts"] = list(self.messages)
return value return value
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot] [tool.jwebot]
release = "alpha 0.6.3.1015" release = "beta 1.0.0.1016"
production_deployments_enabled_by_default = false production_deployments_enabled_by_default = false
[tool.jwebot.workspace] [tool.jwebot.workspace]
+4
View File
@@ -50,6 +50,8 @@ class DeploymentScriptTests(unittest.TestCase):
def test_user_installer_owns_the_complete_adaptive_runtime(self): def test_user_installer_owns_the_complete_adaptive_runtime(self):
source = USER_INSTALLER.read_text(encoding="utf-8") source = USER_INSTALLER.read_text(encoding="utf-8")
for required in ( for required in (
"CHANGELOG.md",
"VERSION",
"wechat_local_ocr.py", "wechat_local_ocr.py",
"wechat_health_watch.py", "wechat_health_watch.py",
"wechat_adaptive_recorder.py", "wechat_adaptive_recorder.py",
@@ -60,6 +62,8 @@ class DeploymentScriptTests(unittest.TestCase):
"jwebot_sdk", "jwebot_sdk",
"jwebot_kernel", "jwebot_kernel",
"jwebot_official_extensions", "jwebot_official_extensions",
"data/cs_autochess",
"official_autochess_data_source_dir",
): ):
self.assertIn(required, source) self.assertIn(required, source)
+3
View File
@@ -867,6 +867,9 @@ class LedgerAndApplicationTests(unittest.TestCase):
self.assertEqual(version["route"], "local") self.assertEqual(version["route"], "local")
self.assertIn("JWeBot 版本", version["text"]) self.assertIn("JWeBot 版本", version["text"])
self.assertIn("kernel", version["text"]) self.assertIn("kernel", version["text"])
self.assertIn("最新版本:beta 1.0.0.1016", version["text"])
self.assertIn("更新时间:2026-10-16", version["text"])
self.assertIn("更新摘要:Promote JWeBot", version["text"])
self.assertEqual(debug["route"], "local") self.assertEqual(debug["route"], "local")
self.assertIn("调试埋点", debug["text"]) self.assertIn("调试埋点", debug["text"])
self.assertEqual(balance["route"], "local") self.assertEqual(balance["route"], "local")
@@ -83,12 +83,16 @@ class LocalExtensionInventoryTests(unittest.TestCase):
"context-summary": OFFICIAL_AUTHOR, "context-summary": OFFICIAL_AUTHOR,
"person-directory": OFFICIAL_AUTHOR, "person-directory": OFFICIAL_AUTHOR,
"single-player-games": JWEBOT_AUTHOR, "single-player-games": JWEBOT_AUTHOR,
"cs-major-autochess": JWEBOT_AUTHOR,
"person-memory": OFFICIAL_AUTHOR, "person-memory": OFFICIAL_AUTHOR,
"server-status": OFFICIAL_AUTHOR, "server-status": OFFICIAL_AUTHOR,
"jwebot-version": OFFICIAL_AUTHOR, "jwebot-version": OFFICIAL_AUTHOR,
"debug-telemetry": OFFICIAL_AUTHOR, "debug-telemetry": OFFICIAL_AUTHOR,
"dice": JWEBOT_AUTHOR, "dice": JWEBOT_AUTHOR,
"coin": JWEBOT_AUTHOR, "coin": JWEBOT_AUTHOR,
"coin-transfer": JWEBOT_AUTHOR,
"coin-ranking": JWEBOT_AUTHOR,
"coin-loan": JWEBOT_AUTHOR,
"random-number": JWEBOT_AUTHOR, "random-number": JWEBOT_AUTHOR,
"draw-lot": JWEBOT_AUTHOR, "draw-lot": JWEBOT_AUTHOR,
"daily-fortune": JWEBOT_AUTHOR, "daily-fortune": JWEBOT_AUTHOR,
@@ -126,6 +130,7 @@ class LocalExtensionInventoryTests(unittest.TestCase):
"person_memory", "person_memory",
"memory_help", "memory_help",
), ),
"coin-loan": ("loan", "repay"),
"server-status": ("local_status", "server_status"), "server-status": ("local_status", "server_status"),
"dice": ("dice", "dice_challenge"), "dice": ("dice", "dice_challenge"),
}, },
+71
View File
@@ -191,6 +191,77 @@ class Release063GameTests(unittest.TestCase):
self.assertIn("alice #2:抽奖 未中奖", history) self.assertIn("alice #2:抽奖 未中奖", history)
self.assertIn("alice #1:抽奖 未中奖", history) self.assertIn("alice #1:抽奖 未中奖", history)
def test_transfer_requires_initialized_target_and_replays_once(self):
self.assertIn("金币 1000", self.command("/金币"))
missing = self.command("/转账 Bob 100", event_id="missing-target")
self.assertIn("bob 还没有金币账户", missing)
self.assertEqual(self.state()["balance"], 1000)
self.assertIn("金币 1000", self.command("/金币", sender="Bob"))
first = self.command("/转账 Bob 250", event_id="transfer-1")
replay = self.command(
"/转账 Bob 250",
sender="a1",
event_id="transfer-1",
)
self.assertEqual(replay, first)
self.assertIn("alice:已向 bob 转账 250 金币", first)
self.assertEqual(self.state()["balance"], 750)
self.assertEqual(self.state("Bob")["balance"], 1250)
def test_transfer_rejects_invalid_self_and_insufficient_cases(self):
self.assertIn("金币 1000", self.command("/金币"))
self.assertIn("转账金额必须为 1~100000", self.command("/转账 Bob 0"))
self.assertIn("不能给自己转账", self.command("/转账 a1 100"))
self.assertIn("金币 1000", self.command("/金币", sender="Bob"))
self.assertIn("金币不足", self.command("/transfer 1001 Bob"))
self.assertEqual(self.state()["balance"], 1000)
self.assertEqual(self.state("Bob")["balance"], 1000)
def test_loan_repay_and_replay_once(self):
self.assertIn("金币 1000", self.command("/金币"))
first = self.command("/贷款 300", event_id="loan-1")
replay = self.command("/贷款 300", event_id="loan-1")
self.assertEqual(replay, first)
self.assertIn("贷款 300 金币已到账", first)
self.assertEqual(self.state()["balance"], 1300)
self.assertEqual(self.state()["loan"]["principal"], 300)
self.assertIn("未还贷款 300", self.command("/金币"))
self.assertIn("已有未还贷款 300", self.command("/贷款 100"))
partial = self.command("/还款 120")
self.assertIn("已还款 120 金币", partial)
self.assertEqual(self.state()["balance"], 1180)
self.assertEqual(self.state()["loan"]["principal"], 180)
done = self.command("/还款 全部")
self.assertIn("已还清贷款 180 金币", done)
self.assertEqual(self.state()["balance"], 1000)
self.assertIsNone(self.state()["loan"])
def test_loan_rejects_invalid_amounts_and_empty_repay(self):
self.assertIn("贷款金额必须为 1~500", self.command("/贷款 0"))
self.assertIn("贷款金额必须为 1~500", self.command("/loan 501"))
self.assertIn("当前没有未还贷款", self.command("/还款 全部"))
def test_coin_ranking_lists_accounts_by_balance(self):
self.assertIn("金币 1000", self.command("/金币"))
self.assertIn("金币 1000", self.command("/金币", sender="Bob"))
self.assertIn("金币 1000", self.command("/金币", sender="Carol"))
self.command("/转账 Bob 250")
self.command("/贷款 100", sender="Carol")
ranking = self.command("/大富翁榜")
self.assertIn("大富翁榜:", ranking)
self.assertIn("1. bob 1250 金币", ranking)
self.assertIn("2. carol 1100 金币(未还贷款 100)", ranking)
self.assertIn("3. alice 750 金币", ranking)
self.assertNotIn("Dave", ranking)
self.assertIn("金币排行人数必须为 1~20", self.command("/金币排行 0"))
def test_open_round_and_history_survive_restart(self): def test_open_round_and_history_survive_restart(self):
root = Path(self.temp.name) root = Path(self.temp.name)
self.command("/彩票开始") self.command("/彩票开始")
+188
View File
@@ -0,0 +1,188 @@
"""Behavioral coverage for the CS Major autochess 0.6.6 flow."""
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from test_jwebot_edge import MODULE, config
from jwebot_official_extensions import cs_autochess as AUTOCHESS
class Release064AutochessTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.engine = MODULE.EdgeApplication(config(Path(self.temp.name)))
self.counter = 0
def tearDown(self):
self.engine.close()
self.temp.cleanup()
def command(self, question, *, sender="Alice", event_id=None):
self.counter += 1
response = self.engine.accept(
{
"event_id": event_id or f"autochess-{self.counter}",
"group_fingerprint": "group-a",
"sender": sender,
"text": "@JWeBot " + question,
"mention_bot": True,
"confidence": 1.0,
"sender_confidence": 1.0,
"body_confidence": 1.0,
}
)
self.assertEqual(response["status"], "reply")
return list(response.get("texts") or [response["text"]])
def state(self):
row = self.engine.ledger.connection.execute(
"""
SELECT state_json FROM autochess_runs
WHERE group_fingerprint = 'group-a'
ORDER BY season_number DESC
LIMIT 1
"""
).fetchone()
self.assertIsNotNone(row)
return json.loads(row[0])
def start_two_human_two_ai(self):
self.command("/自走棋开始")
self.command("/自走棋报名", sender="Bob")
self.command("/自走棋加AI战队")
return self.command("/自走棋开赛")
def finish_draft(self):
self.command("/选1")
self.command("/选1", sender="Bob")
self.command("/选1", sender="Bob")
self.command("/选1")
self.command("/选1")
return self.command("/选1", sender="Bob")
def ready_all(self):
state = self.state()
replies = []
for key in list(state.get("ready_keys") or []):
replies.extend(
self.command(
"/结束回合",
sender=state["players"][key]["display_name"],
)
)
return replies
def test_static_pool_parser_and_coach_pool(self):
self.assertGreaterEqual(len(AUTOCHESS.PLAYER_POOL), 100)
self.assertEqual(
len({card["id"] for card in AUTOCHESS.PLAYER_POOL}),
len(AUTOCHESS.PLAYER_POOL),
)
self.assertGreaterEqual(len(AUTOCHESS.COACH_POOL), 18)
self.assertEqual(
len({coach["id"] for coach in AUTOCHESS.COACH_POOL}),
len(AUTOCHESS.COACH_POOL),
)
self.assertEqual(
AUTOCHESS.parse_autochess_action("/自走棋开始"),
("create", ""),
)
self.assertEqual(
AUTOCHESS.parse_autochess_action("/选6"),
("buy", "6"),
)
self.assertEqual(
AUTOCHESS.parse_autochess_action("/战术残局"),
("tactic", "残局"),
)
def test_launch_starts_multimessage_coach_draft_not_full_rosters(self):
response = self.start_two_human_two_ai()
state = self.state()
self.assertGreaterEqual(len(response), 5)
self.assertIn("第一轮选秀", "\n".join(response))
self.assertIn("1. zonic", "\n".join(response))
self.assertEqual(state["phase"], "draft_coach_1")
self.assertEqual(state["ai_test_variant"], "two_plus_two")
self.assertEqual(
state["seed_order"][2:],
[AUTOCHESS.AI_PERSON_KEY, AUTOCHESS.AI_SECOND_PERSON_KEY],
)
for player in state["players"].values():
self.assertEqual(player["board"], [])
self.assertEqual(player["coach_id"], "")
def test_three_round_draft_training_rounds_and_group_intro(self):
self.start_two_human_two_ai()
final_draft = self.finish_draft()
state = self.state()
self.assertIn("三轮选秀结束", "\n".join(final_draft))
self.assertEqual(state["phase"], "lineup_prep")
for player in state["players"].values():
self.assertEqual(len(player["board"]), 5)
self.assertEqual(len(player["bench"]), 3)
self.assertTrue(player["coach_id"])
training = self.ready_all()
training_text = "\n".join(training)
self.assertIn("训练赛第 1/3 轮", training_text)
self.assertIn("Team", training_text)
self.assertNotIn("目标", training_text)
self.assertNotIn("发挥", training_text)
self.assertEqual(self.state()["phase"], "training_manage")
round_two = "\n".join(self.ready_all())
self.assertIn("训练赛第 2/3 轮", round_two)
round_three = "\n".join(self.ready_all())
self.assertIn("训练赛第 3/3 轮", round_three)
self.assertEqual(self.state()["phase"], "training_complete_manage")
group = self.ready_all()
self.assertIn("正赛小组赛", "\n".join(group))
state = self.state()
self.assertEqual(state["phase"], "group_ready")
self.assertEqual(len(state["group_matches"]), 6)
def test_full_tournament_completes_and_only_active_playoff_players_ready(self):
self.start_two_human_two_ai()
self.finish_draft()
seen_playoff_prompt = False
for _ in range(80):
state = self.state()
if state["status"] == "completed":
break
ready_keys = list(state.get("ready_keys") or [])
self.assertTrue(ready_keys, state["phase"])
if state["phase"] in {"semifinal_ready", "final_ready"}:
pair = set(state["current_match"]["pair"])
self.assertEqual(set(ready_keys), pair & set(AUTOCHESS._human_keys(state)))
seen_playoff_prompt = True
self.ready_all()
else:
self.fail("tournament did not complete")
state = self.state()
self.assertTrue(seen_playoff_prompt)
self.assertEqual(state["status"], "completed")
self.assertEqual(state["phase"], "completed")
self.assertTrue(state["champion_key"])
def test_multimessage_response_replays_identically(self):
self.command("/自走棋开始")
self.command("/自走棋报名", sender="Bob")
self.command("/自走棋加AI战队")
first = self.command("/自走棋开赛", event_id="same-launch")
replay = self.command("/自走棋开赛", event_id="same-launch")
self.assertEqual(first, replay)
self.assertGreater(len(first), 1)
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More