From bbddb5be6f4b8d4bf6ee48fc3c9a9981af43df65 Mon Sep 17 00:00:00 2001 From: JWeBot Contributors Date: Thu, 15 Oct 2026 18:43:49 +0800 Subject: [PATCH] Add virtual coin loans --- CHANGELOG.md | 11 ++ README.md | 10 +- VERSION | 2 +- docs/local-plugins-0.6.1.md | 8 +- extensions/jwebot/coin-loan/README.md | 22 ++++ extensions/jwebot/coin-loan/plugin.toml | 14 +++ extensions/jwebot/coin-loan/src/coin_loan.py | 45 ++++++++ .../jwebot/coin-loan/tests/test_plugin.py | 29 +++++ packages/jwebot-config/pyproject.toml | 2 +- .../src/jwebot_config/__init__.py | 2 +- packages/jwebot-extension-host/pyproject.toml | 6 +- .../src/jwebot_extension_host/__init__.py | 2 +- packages/jwebot-kernel/pyproject.toml | 8 +- .../src/jwebot_kernel/__init__.py | 2 +- .../jwebot-official-extensions/pyproject.toml | 4 +- .../jwebot_official_extensions/__init__.py | 2 +- .../adapters/coin_loan.py | 45 ++++++++ .../jwebot_official_extensions/descriptors.py | 13 +++ .../local_commands.py | 2 + .../player_games.py | 107 +++++++++++++++++- packages/jwebot-sdk/pyproject.toml | 2 +- .../jwebot-sdk/src/jwebot_sdk/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_official_extension_inventory.py | 2 + tests/test_release_063.py | 27 +++++ 25 files changed, 346 insertions(+), 25 deletions(-) create mode 100644 extensions/jwebot/coin-loan/README.md create mode 100644 extensions/jwebot/coin-loan/plugin.toml create mode 100644 extensions/jwebot/coin-loan/src/coin_loan.py create mode 100644 extensions/jwebot/coin-loan/tests/test_plugin.py create mode 100644 packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_loan.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a0ad5..476b648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 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 diff --git a/README.md b/README.md index 717a345..82159b3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 并通过带视觉确认的 GUI 发送链路回复。 -当前版本:`alpha 0.6.9.1015`。 +当前版本:`alpha 0.6.10.1015`。 0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加 两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见 @@ -41,6 +41,8 @@ D 牌、锁店、调阵容和战术窗口会在轮次之间自然提示。 MR15/加时参数都由数据文件驱动,启动时会校验字段、ID 和素材数量。 同版新增虚拟金币转账:`/转账 对方 金额` 会在同一事务内扣款入账, 收款人需先发送 `/金币` 初始化账户。 +0.6.10 新增虚拟金币贷款:`/贷款 [金额]` 最多借入 500 金币,`/还款` +可按金额或全部归还;每个账户同一时间最多一笔未还贷款。 ### 运行流程 @@ -108,6 +110,7 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh /人物列表 [页码](/people);/人物 [名称或别名](/person) /金币(/wallet);/抽奖 [金额](/draw) /转账 对方 金额(/transfer name amount) +/贷款 [金额](/loan amount);/还款 [金额|全部](/repay) /彩票 开始|报名|当前状态|开奖|取消(/lottery) /21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 /游戏记录 [页码](/gamehistory) @@ -284,7 +287,7 @@ visually confirmed GUI bridge. Current version: ```text -alpha 0.6.9.1015 +alpha 0.6.10.1015 ``` ## Runtime Flow @@ -387,7 +390,8 @@ single-player virtual coin games. Chinese aliases share the same implementations Accounts receive 1000 virtual coins once; funds, game state and event replay results commit together. `/transfer name amount` moves virtual coins between initialized group-scoped accounts in one ledger transaction. OCR display aliases -identify group-scoped people; native WeChat account IDs are unavailable. See +identify group-scoped people; `/loan amount` and `/repay` manage one capped +virtual coin loan per account. Native WeChat account IDs are unavailable. See [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 diff --git a/VERSION b/VERSION index 168a7de..7b63817 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -alpha 0.6.9.1015 +alpha 0.6.10.1015 diff --git a/docs/local-plugins-0.6.1.md b/docs/local-plugins-0.6.1.md index 3ebf2ed..53c0060 100644 --- a/docs/local-plugins-0.6.1.md +++ b/docs/local-plugins-0.6.1.md @@ -9,6 +9,7 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写, | context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` | | single-player-games | `/金币` | `/wallet` | | coin-transfer | `/转账 对方 金额` | `/transfer name amount` | +| coin-loan | `/贷款 [金额]`、`/还款 [金额\|全部]` | `/loan amount`、`/repay [amount\|all]` | | single-player-games | `/抽奖 [金额]` | `/draw [金额]` | | single-player-games | `/彩票 开始/报名/当前状态/开奖/取消` | `/lottery start/join/status/draw/cancel` | | single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) | @@ -52,7 +53,8 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使 昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域; 应优先追加别名而不是重命名。除 `/转账` 外,用户不能通过游戏命令 指定他人的账户;转账只支持同群虚拟金币账户,收款人需先发送 `/金币` -初始化。虚拟金币不能充值、赠币或兑换真实资产。 +初始化。`/贷款` 和 `/还款` 只作用于本人账户。虚拟金币不能充值、 +赠币或兑换真实资产。 ## 虚拟金币与游戏管理 @@ -65,6 +67,10 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使 转账、响应记录和重试去重写在同一个 ledger 事务内,同一 event_id 不会重复扣款。 +`/贷款 [金额]` 会给本人账户借入 1~500 金币;同一账户同一时间最多 +一笔未还贷款。`/还款 [金额|全部]` 从当前金币余额中扣除并减少未还 +贷款,借款、还款、响应记录和重试去重同样写在一个 ledger 事务内。 + 抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。 alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关, 下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见 diff --git a/extensions/jwebot/coin-loan/README.md b/extensions/jwebot/coin-loan/README.md new file mode 100644 index 0000000..f1d1119 --- /dev/null +++ b/extensions/jwebot/coin-loan/README.md @@ -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`. diff --git a/extensions/jwebot/coin-loan/plugin.toml b/extensions/jwebot/coin-loan/plugin.toml new file mode 100644 index 0000000..bfbcc82 --- /dev/null +++ b/extensions/jwebot/coin-loan/plugin.toml @@ -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"] diff --git a/extensions/jwebot/coin-loan/src/coin_loan.py b/extensions/jwebot/coin-loan/src/coin_loan.py new file mode 100644 index 0000000..8367363 --- /dev/null +++ b/extensions/jwebot/coin-loan/src/coin_loan.py @@ -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)) diff --git a/extensions/jwebot/coin-loan/tests/test_plugin.py b/extensions/jwebot/coin-loan/tests/test_plugin.py new file mode 100644 index 0000000..116479a --- /dev/null +++ b/extensions/jwebot/coin-loan/tests/test_plugin.py @@ -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() diff --git a/packages/jwebot-config/pyproject.toml b/packages/jwebot-config/pyproject.toml index 635ec2a..4d6d5f4 100644 --- a/packages/jwebot-config/pyproject.toml +++ b/packages/jwebot-config/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-config" -version = "0.6.9a0+1015" +version = "0.6.10a0+1015" description = "Profile-based configuration compiler for JWeBot" requires-python = ">=3.9" dependencies = [ diff --git a/packages/jwebot-config/src/jwebot_config/__init__.py b/packages/jwebot-config/src/jwebot_config/__init__.py index 1e9fd3b..3938097 100644 --- a/packages/jwebot-config/src/jwebot_config/__init__.py +++ b/packages/jwebot-config/src/jwebot_config/__init__.py @@ -36,4 +36,4 @@ __all__ = [ "write_default_profile", ] -__version__ = "0.6.9a0+1015" +__version__ = "0.6.10a0+1015" diff --git a/packages/jwebot-extension-host/pyproject.toml b/packages/jwebot-extension-host/pyproject.toml index fe698a1..71d06bc 100644 --- a/packages/jwebot-extension-host/pyproject.toml +++ b/packages/jwebot-extension-host/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-extension-host" -version = "0.6.9a0+1015" +version = "0.6.10a0+1015" description = "Single-release, single-invocation process host for JWeBot extensions" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.9a0+1015", - "jwebot-kernel==0.6.9a0+1015", + "jwebot-sdk==0.6.10a0+1015", + "jwebot-kernel==0.6.10a0+1015", ] [project.scripts] diff --git a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py index dfa0df5..31dfac1 100644 --- a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py +++ b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py @@ -16,4 +16,4 @@ __all__ = [ "process_request", ] -__version__ = "0.6.9a0+1015" +__version__ = "0.6.10a0+1015" diff --git a/packages/jwebot-kernel/pyproject.toml b/packages/jwebot-kernel/pyproject.toml index 63d71da..55a7fcd 100644 --- a/packages/jwebot-kernel/pyproject.toml +++ b/packages/jwebot-kernel/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-kernel" -version = "0.6.9a0+1015" +version = "0.6.10a0+1015" description = "Stable routing and extension authority for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-config==0.6.9a0+1015", - "jwebot-sdk==0.6.9a0+1015", - "jwebot-official-extensions==0.6.9a0+1015", + "jwebot-config==0.6.10a0+1015", + "jwebot-sdk==0.6.10a0+1015", + "jwebot-official-extensions==0.6.10a0+1015", "tomli>=2; python_version < '3.11'", ] diff --git a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py index 6cbb35a..1dfbd4a 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py @@ -107,4 +107,4 @@ __all__ = [ "verify_installed_release_signature", ] -__version__ = "0.6.9a0+1015" +__version__ = "0.6.10a0+1015" diff --git a/packages/jwebot-official-extensions/pyproject.toml b/packages/jwebot-official-extensions/pyproject.toml index 8f7ae74..6439f1e 100644 --- a/packages/jwebot-official-extensions/pyproject.toml +++ b/packages/jwebot-official-extensions/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-official-extensions" -version = "0.6.9a0+1015" +version = "0.6.10a0+1015" description = "Reviewed local command adapters and broker implementation for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.9a0+1015", + "jwebot-sdk==0.6.10a0+1015", ] [tool.setuptools.packages.find] diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py index bf07804..3804650 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py @@ -90,4 +90,4 @@ __all__ = [ "render_feature_list", ] -__version__ = "0.6.9a0+1015" +__version__ = "0.6.10a0+1015" diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_loan.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_loan.py new file mode 100644 index 0000000..8367363 --- /dev/null +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_loan.py @@ -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)) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py index 6307626..a5b7f42 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py @@ -44,6 +44,12 @@ LOCAL_COMMAND_ALIASES = { "wallet": "wallet", "转账": "transfer", "transfer": "transfer", + "贷款": "loan", + "借款": "loan", + "loan": "loan", + "borrow": "loan", + "还款": "repay", + "repay": "repay", "抽奖": "prize_draw", "draw": "prize_draw", "彩票": "lottery", @@ -165,6 +171,13 @@ LOCAL_EXTENSIONS = ( help_entries=("/转账 对方 金额;/transfer",), command_kinds=("transfer",), ), + LocalExtensionDescriptor( + extension_id="coin-loan", + author=JWEBOT_AUTHOR, + description="贷款", + help_entries=("/贷款;/还款",), + command_kinds=("loan", "repay"), + ), LocalExtensionDescriptor( extension_id="cs-major-autochess", author=JWEBOT_AUTHOR, diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py index f29f63f..337ddb8 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py @@ -155,6 +155,8 @@ class LocalCommandEngine: if kind in { "wallet", "transfer", + "loan", + "repay", "prize_draw", "lottery", "blackjack", diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py index 6c6df54..facbb55 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py @@ -18,6 +18,8 @@ MIN_BLACKJACK_BET = 1 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_HISTORY_LIMIT = 50 GAME_HISTORY_PAGE_SIZE = 5 @@ -53,6 +55,18 @@ TRANSFER_MARKED = re.compile( def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]: account.setdefault("balance", INITIAL_COINS) 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("losses", 0) account.setdefault("ties", 0) @@ -137,6 +151,8 @@ class PlayerGames: "每人首次使用获 1000 虚拟金币;/金币 查看余额;/游戏记录 查看本人记录。\n" "转账:/转账 对方 金额,或 /transfer name amount;" "对方需先用 /金币 初始化账户。\n" + "贷款:/贷款 [1~500] 借入金币;/还款 [金额|全部] 归还," + "同一时间最多一笔未还贷款。\n" "抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n" "多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];" "/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;" @@ -161,6 +177,8 @@ class PlayerGames: return self.group_lottery(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: return "用法:/金币(/wallet),查看本人的虚拟金币;/游戏帮助 查看规则" if kind == "prize_draw": @@ -233,9 +251,13 @@ class PlayerGames: expired = "上一局已超时,按认输结束;" if kind == "wallet": state = "进行中" if account.get("game") else "无进行中的牌局" - return (f"{expired}金币 {account['balance']};21点 {state};" - f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。" - "首次赠送1000仅发一次;/游戏帮助 查看规则") + 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']}。" + "首次赠送1000仅发一次;/游戏帮助 查看规则" + ) if kind == "blackjack" and action in {"hit", "stand", "status", "end"}: active = account.get("game") index = int(active["index"]) if active else None @@ -366,6 +388,85 @@ class PlayerGames: 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 def parse_lottery_entry( arguments: str, diff --git a/packages/jwebot-sdk/pyproject.toml b/packages/jwebot-sdk/pyproject.toml index f571060..3528279 100644 --- a/packages/jwebot-sdk/pyproject.toml +++ b/packages/jwebot-sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-sdk" -version = "0.6.9a0+1015" +version = "0.6.10a0+1015" description = "Typed, capability-limited extension API for JWeBot plugins" requires-python = ">=3.9" dependencies = [] diff --git a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py index b680006..c53af5a 100644 --- a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py +++ b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py @@ -38,4 +38,4 @@ __all__ = [ "SingleGroupContext", ] -__version__ = "0.6.9a0+1015" +__version__ = "0.6.10a0+1015" diff --git a/pyproject.toml b/pyproject.toml index ec5fd7e..62a1edc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.jwebot] -release = "alpha 0.6.9.1015" +release = "alpha 0.6.10.1015" production_deployments_enabled_by_default = false [tool.jwebot.workspace] diff --git a/tests/test_official_extension_inventory.py b/tests/test_official_extension_inventory.py index dcdaae2..c2eb1d7 100644 --- a/tests/test_official_extension_inventory.py +++ b/tests/test_official_extension_inventory.py @@ -91,6 +91,7 @@ class LocalExtensionInventoryTests(unittest.TestCase): "dice": JWEBOT_AUTHOR, "coin": JWEBOT_AUTHOR, "coin-transfer": JWEBOT_AUTHOR, + "coin-loan": JWEBOT_AUTHOR, "random-number": JWEBOT_AUTHOR, "draw-lot": JWEBOT_AUTHOR, "daily-fortune": JWEBOT_AUTHOR, @@ -128,6 +129,7 @@ class LocalExtensionInventoryTests(unittest.TestCase): "person_memory", "memory_help", ), + "coin-loan": ("loan", "repay"), "server-status": ("local_status", "server_status"), "dice": ("dice", "dice_challenge"), }, diff --git a/tests/test_release_063.py b/tests/test_release_063.py index 6f5ad15..d7dc675 100644 --- a/tests/test_release_063.py +++ b/tests/test_release_063.py @@ -219,6 +219,33 @@ class Release063GameTests(unittest.TestCase): 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_open_round_and_history_survive_restart(self): root = Path(self.temp.name) self.command("/彩票开始")