diff --git a/README.md b/README.md index c04aa47..959467f 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh /贷款 [金额](/loan amount);/还款 [金额|全部](/repay) /彩票 开始|报名|当前状态|开奖|取消(/lottery) /21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 +/21点 多人 启动;/21点 加入;/21点 多人 开始;/21点 放200 /游戏记录 [页码](/gamehistory) /游戏帮助(/games) /自走棋开始|报名|开赛|状态|档案 diff --git a/docs/command-catalog.md b/docs/command-catalog.md index 75ad273..852bce9 100644 --- a/docs/command-catalog.md +++ b/docs/command-catalog.md @@ -57,6 +57,10 @@ descriptor inventory; plugin code delegates to reviewed service profiles. | `/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. | +| `/21点 多人 启动` | - | Open a Taffy-hosted blackjack table and auto-seat the host. | +| `/21点 加入` | - | Join the current blackjack table before betting starts. | +| `/21点 多人 开始` | - | Host-only close joining and start ordered betting prompts. | +| `/21点 放金额` | - | Place the prompted table stake, 1-10000 coins. | | `/游戏记录 [页码]` | `/gamehistory` | Show indexed personal game history. | | `/游戏帮助` | `/games` | Show local game rules. | diff --git a/docs/local-plugins-0.6.1.md b/docs/local-plugins-0.6.1.md index 8ec2fe2..57229bf 100644 --- a/docs/local-plugins-0.6.1.md +++ b/docs/local-plugins-0.6.1.md @@ -90,6 +90,12 @@ alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数 仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头, 例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、 账户、局号和游戏记录。 + +多人 21 点使用 `/21点 多人 启动` 创建 Taffy 牌桌,开桌者自动入座。 +其他玩家在下注前用 `/21点 加入` 进入当前牌桌;主持人发送 `/21点 多人 开始` +后,Taffy 会按座位顺序提示每个人 `/21点 放200` 放筹码,范围 1~10000。 +所有玩家下注后统一发牌,轮到本人时用 `/21点 要牌` 或 `/21点 停牌` 操作; +结算按座位顺序列出每个人的下注、返还、净变化和当前金币。 自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍, 平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔 抽奖或 21 点开局,多人彩票报名仍可独立进行。 diff --git a/extensions/jwebot/coin-taffy/README.md b/extensions/jwebot/coin-taffy/README.md new file mode 100644 index 0000000..ac7b071 --- /dev/null +++ b/extensions/jwebot/coin-taffy/README.md @@ -0,0 +1,18 @@ +# coin-taffy + +Author: `jwebot` + +Taffy金币 + +Help: + +- `/Taffy金币` + +Registered commands: + +- `/Taffy金币` +- `/taffycoins` + +The adapter has no direct Ledger, GUI, environment, shell, or network access. +It delegates to the reviewed `coin-taffy` Kernel service profile +and returns a structured `ReplyPlan`. diff --git a/extensions/jwebot/coin-taffy/plugin.toml b/extensions/jwebot/coin-taffy/plugin.toml new file mode 100644 index 0000000..0b5f668 --- /dev/null +++ b/extensions/jwebot/coin-taffy/plugin.toml @@ -0,0 +1,14 @@ +id = "coin-taffy" +author = "jwebot" +description = "Taffy金币" +help = ["/Taffy金币"] +version = "1.0.0" +api_version = "1" +entrypoint = "coin_taffy:plugin" +type = "command" + +[capabilities] +commands = ["/Taffy金币", "/taffycoins"] +storage = false +network_profiles = [] +service_profiles = ["coin-taffy"] diff --git a/extensions/jwebot/coin-taffy/src/coin_taffy.py b/extensions/jwebot/coin-taffy/src/coin_taffy.py new file mode 100644 index 0000000..08974dd --- /dev/null +++ b/extensions/jwebot/coin-taffy/src/coin_taffy.py @@ -0,0 +1,45 @@ +"""Generated reviewed adapter for coin-taffy.""" + +from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext + + +PROFILE = 'coin-taffy' +AUTHOR = 'jwebot' +DESCRIPTION = 'Taffy金币' +HELP_ENTRIES = ('/Taffy金币',) +COMMANDS = ('/Taffy金币', '/taffycoins') + + +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-taffy/tests/test_plugin.py b/extensions/jwebot/coin-taffy/tests/test_plugin.py new file mode 100644 index 0000000..abca9d7 --- /dev/null +++ b/extensions/jwebot/coin-taffy/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_taffy 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, 'Taffy金币') + self.assertEqual(HELP_ENTRIES, ('/Taffy金币',)) + self.assertIn('/Taffy金币', registered) + self.assertEqual(len(registered), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/extensions/jwebot/single-player-games/README.md b/extensions/jwebot/single-player-games/README.md index 37cce13..45c7891 100644 --- a/extensions/jwebot/single-player-games/README.md +++ b/extensions/jwebot/single-player-games/README.md @@ -9,8 +9,7 @@ Help: - `/金币 /wallet;/游戏记录 /gamehistory` - `/抽奖 [金额];/draw` - `/彩票 开始|报名|状态|开奖|取消;/lottery` -- `/21点 开始 [1~500];/blackjack start` -- `/21点 要牌|停牌|状态|结束;/blackjack` +- `/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200` - `/游戏帮助;/games` Registered commands: diff --git a/extensions/jwebot/single-player-games/plugin.toml b/extensions/jwebot/single-player-games/plugin.toml index 26f5319..1aa09d5 100644 --- a/extensions/jwebot/single-player-games/plugin.toml +++ b/extensions/jwebot/single-player-games/plugin.toml @@ -1,7 +1,7 @@ id = "single-player-games" author = "jwebot" description = "金币游戏" -help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 开始 [1~500];/blackjack start", "/21点 要牌|停牌|状态|结束;/blackjack", "/游戏帮助;/games"] +help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200", "/游戏帮助;/games"] version = "1.0.0" api_version = "1" entrypoint = "single_player_games:plugin" diff --git a/extensions/jwebot/single-player-games/src/single_player_games.py b/extensions/jwebot/single-player-games/src/single_player_games.py index a568ecf..d783010 100644 --- a/extensions/jwebot/single-player-games/src/single_player_games.py +++ b/extensions/jwebot/single-player-games/src/single_player_games.py @@ -6,7 +6,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext PROFILE = 'single-player-games' AUTHOR = 'jwebot' DESCRIPTION = '金币游戏' -HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games') +HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games') COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') diff --git a/extensions/jwebot/single-player-games/tests/test_plugin.py b/extensions/jwebot/single-player-games/tests/test_plugin.py index 742d724..1da4e6e 100644 --- a/extensions/jwebot/single-player-games/tests/test_plugin.py +++ b/extensions/jwebot/single-player-games/tests/test_plugin.py @@ -20,7 +20,7 @@ class PluginTests(unittest.TestCase): ] self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(DESCRIPTION, '金币游戏') - self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')) + self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games')) self.assertIn('/金币', registered) self.assertEqual(len(registered), 15) diff --git a/extensions/official/context-summary/README.md b/extensions/official/context-summary/README.md index ee08aab..f7efefd 100644 --- a/extensions/official/context-summary/README.md +++ b/extensions/official/context-summary/README.md @@ -2,7 +2,7 @@ Author: `official` -提取当前群最近100、200或300条本地文字的上下文 +上下文总结 Help: diff --git a/extensions/official/context-summary/plugin.toml b/extensions/official/context-summary/plugin.toml index 5cd899f..53b5c1e 100644 --- a/extensions/official/context-summary/plugin.toml +++ b/extensions/official/context-summary/plugin.toml @@ -1,6 +1,6 @@ id = "context-summary" author = "official" -description = "提取当前群最近100、200或300条本地文字的上下文" +description = "上下文总结" help = ["/上下文 [100|200|300];/context [100|200|300]"] version = "1.0.0" api_version = "1" diff --git a/extensions/official/context-summary/src/context_summary.py b/extensions/official/context-summary/src/context_summary.py index ec5e678..95e0da4 100644 --- a/extensions/official/context-summary/src/context_summary.py +++ b/extensions/official/context-summary/src/context_summary.py @@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext PROFILE = 'context-summary' AUTHOR = 'official' -DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文' +DESCRIPTION = '上下文总结' HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',) COMMANDS = ('/上下文', '/context') diff --git a/extensions/official/context-summary/tests/test_plugin.py b/extensions/official/context-summary/tests/test_plugin.py index 21d9bc0..8c5c696 100644 --- a/extensions/official/context-summary/tests/test_plugin.py +++ b/extensions/official/context-summary/tests/test_plugin.py @@ -19,7 +19,7 @@ class PluginTests(unittest.TestCase): for call in context.commands.register.call_args_list ] self.assertEqual(AUTHOR, 'official') - self.assertEqual(DESCRIPTION, '提取当前群最近100、200或300条本地文字的上下文') + self.assertEqual(DESCRIPTION, '上下文总结') self.assertEqual(HELP_ENTRIES, ('/上下文 [100|200|300];/context [100|200|300]',)) self.assertIn('/上下文', registered) self.assertEqual(len(registered), 2) diff --git a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py index 315940f..6dcf4cd 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py @@ -72,6 +72,7 @@ from jwebot_official_extensions.steam import ( ) from jwebot_official_extensions.player_games import ( append_game_record, + hand_value, next_game_index, normalize_player_account, ) @@ -1072,6 +1073,49 @@ class Ledger: ON group_lottery_entries( group_fingerprint, round_number, created_at, person_key ); + CREATE TABLE IF NOT EXISTS group_blackjack_tables ( + group_fingerprint TEXT NOT NULL, + table_number INTEGER NOT NULL, + state TEXT NOT NULL, + host_person_key TEXT NOT NULL, + host_display_name TEXT NOT NULL, + dealer_json TEXT NOT NULL DEFAULT '[]', + deck_json TEXT NOT NULL DEFAULT '[]', + turn_person_key TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (group_fingerprint, table_number) + ); + CREATE TABLE IF NOT EXISTS group_blackjack_players ( + group_fingerprint TEXT NOT NULL, + table_number INTEGER NOT NULL, + person_key TEXT NOT NULL, + display_name TEXT NOT NULL, + seat_order INTEGER NOT NULL, + player_game_index INTEGER NOT NULL, + bet INTEGER NOT NULL DEFAULT 0, + hand_json TEXT NOT NULL DEFAULT '[]', + state TEXT NOT NULL, + outcome TEXT NOT NULL DEFAULT '', + payout INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY ( + group_fingerprint, table_number, person_key + ), + FOREIGN KEY (group_fingerprint, table_number) + REFERENCES group_blackjack_tables ( + group_fingerprint, table_number + ) + ON DELETE CASCADE + ); + CREATE UNIQUE INDEX IF NOT EXISTS group_blackjack_one_active + ON group_blackjack_tables(group_fingerprint) + WHERE state IN ('joining', 'betting', 'playing'); + CREATE INDEX IF NOT EXISTS group_blackjack_players_order + ON group_blackjack_players( + group_fingerprint, table_number, seat_order, person_key + ); CREATE TABLE IF NOT EXISTS autochess_runs ( group_fingerprint TEXT NOT NULL, run_id TEXT NOT NULL, @@ -3644,6 +3688,1048 @@ class Ledger: self.connection.execute("ROLLBACK") raise + @staticmethod + def _blackjack_cards(value: object) -> list[int]: + try: + cards = json.loads(str(value)) + except json.JSONDecodeError: + return [] + if not isinstance(cards, list): + return [] + return [ + int(card) + for card in cards + if type(card) is int and 0 <= card < 52 + ] + + def _open_group_blackjack(self, group: str) -> sqlite3.Row | None: + return self.connection.execute( + """ + SELECT * FROM group_blackjack_tables + WHERE group_fingerprint = ? + AND state IN ('joining', 'betting', 'playing') + LIMIT 1 + """, + (group,), + ).fetchone() + + def has_active_group_blackjack(self, group: str) -> bool: + group = bounded_text(group, limit=256) + with self.lock: + return self._open_group_blackjack(group) is not None + + def _group_blackjack_players( + self, + group: str, + table_number: int, + ) -> list[dict[str, Any]]: + rows = self.connection.execute( + """ + SELECT * FROM group_blackjack_players + WHERE group_fingerprint = ? AND table_number = ? + ORDER BY seat_order, person_key + """, + (group, table_number), + ).fetchall() + players: list[dict[str, Any]] = [] + for row in rows: + player = dict(row) + player["hand"] = self._blackjack_cards(player["hand_json"]) + players.append(player) + return players + + @staticmethod + def _blackjack_entry( + players: list[dict[str, Any]], + person_key: str, + ) -> dict[str, Any] | None: + for player in players: + if str(player["person_key"]) == person_key: + return player + return None + + @staticmethod + def _blackjack_next_player( + players: list[dict[str, Any]], + *, + after_order: int = 0, + ) -> dict[str, Any] | None: + for player in players: + if ( + int(player["seat_order"]) > after_order + and str(player["state"]) == "playing" + ): + return player + return None + + @staticmethod + def _blackjack_outcome( + hand: list[int], + dealer: list[int], + state: str, + bet: int, + ) -> tuple[str, int, str, int, int]: + player_value = hand_value(hand) + dealer_value = hand_value(dealer) + natural = len(hand) == 2 and player_value == 21 + dealer_natural = len(dealer) == 2 and dealer_value == 21 + if state == "busted" or player_value > 21: + return "你输了", 0, "losses", player_value, dealer_value + if dealer_natural and not natural: + return "你输了", 0, "losses", player_value, dealer_value + if natural and dealer_natural: + return "平局", bet, "ties", player_value, dealer_value + if natural: + return "自然21点获胜", bet * 5 // 2, "wins", player_value, dealer_value + if dealer_value > 21 or player_value > dealer_value: + return "你赢了", bet * 2, "wins", player_value, dealer_value + if player_value == dealer_value: + return "平局", bet, "ties", player_value, dealer_value + return "你输了", 0, "losses", player_value, dealer_value + + def _settle_group_blackjack_locked( + self, + group: str, + table: sqlite3.Row, + players: list[dict[str, Any]], + timestamp: str, + ) -> dict[str, Any]: + table_number = int(table["table_number"]) + dealer = self._blackjack_cards(table["dealer_json"]) + deck = self._blackjack_cards(table["deck_json"]) + if any(hand_value(player["hand"]) <= 21 for player in players): + while hand_value(dealer) < 17 and deck: + dealer.append(deck.pop(0)) + settled: list[dict[str, Any]] = [] + for player in players: + key = str(player["person_key"]) + account = self._load_player_account(group, key) + bet = int(player["bet"]) + outcome, payout, stat, player_value, dealer_value = ( + self._blackjack_outcome( + player["hand"], + dealer, + str(player["state"]), + bet, + ) + ) + account["balance"] += payout + account[stat] += 1 + append_game_record( + account, + kind="blackjack", + index=int(player["player_game_index"]), + outcome=outcome, + stake=bet, + payout=payout, + summary=( + f"多人牌桌第 {table_number} 桌;" + f"玩家{player_value}点,Taffy{dealer_value}点" + ), + ) + self._save_player_account(group, key, account, timestamp) + self.connection.execute( + """ + UPDATE group_blackjack_players + SET state = 'settled', outcome = ?, payout = ?, + updated_at = ? + WHERE group_fingerprint = ? AND table_number = ? + AND person_key = ? + """, + ( + outcome, + payout, + timestamp, + group, + table_number, + key, + ), + ) + result = dict(player) + result.update( + { + "state": "settled", + "outcome": outcome, + "payout": payout, + "balance": int(account["balance"]), + "player_value": player_value, + "dealer_value": dealer_value, + } + ) + settled.append(result) + self.connection.execute( + """ + UPDATE group_blackjack_tables + SET state = 'settled', dealer_json = ?, deck_json = ?, + turn_person_key = '', updated_at = ? + WHERE group_fingerprint = ? AND table_number = ? + """, + ( + canonical_json(dealer), + canonical_json(deck), + timestamp, + group, + table_number, + ), + ) + return { + "status": "settled", + "table_number": table_number, + "dealer": dealer, + "dealer_value": hand_value(dealer), + "players": settled, + } + + def start_group_blackjack( + self, + group: str, + sender: str, + event_id: str, + command: str, + *, + max_players: int, + render: Callable[[dict[str, Any]], str], + ) -> str: + person = self._player_identity(group, sender) + person_key = str(person["person_key"]) + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + replay = self._player_action_replay( + group, event_id, person_key, command + ) + if replay is not None: + self.connection.execute("COMMIT") + return replay + row = self._open_group_blackjack(group) + if row is not None: + data = dict(row) + data["status"] = "already_open" + data["players"] = self._group_blackjack_players( + group, int(row["table_number"]) + ) + else: + account = self._load_player_account(group, person_key) + if account.get("game"): + data = { + "status": "private_game", + "display_name": person["display_name"], + } + else: + table_number = int( + self.connection.execute( + """ + SELECT COALESCE(MAX(table_number), 0) + 1 + FROM group_blackjack_tables + WHERE group_fingerprint = ? + """, + (group,), + ).fetchone()[0] + ) + timestamp = utc_now() + index = next_game_index(account, "blackjack") + self._save_player_account( + group, person_key, account, timestamp + ) + self.connection.execute( + """ + INSERT INTO group_blackjack_tables ( + group_fingerprint, table_number, state, + host_person_key, host_display_name, + dealer_json, deck_json, turn_person_key, + created_at, updated_at + ) VALUES (?, ?, 'joining', ?, ?, '[]', '[]', '', + ?, ?) + """, + ( + group, + table_number, + person_key, + person["display_name"], + timestamp, + timestamp, + ), + ) + self.connection.execute( + """ + INSERT INTO group_blackjack_players ( + group_fingerprint, table_number, person_key, + display_name, seat_order, player_game_index, + bet, hand_json, state, outcome, payout, + created_at, updated_at + ) VALUES (?, ?, ?, ?, 1, ?, 0, '[]', 'joined', + '', 0, ?, ?) + """, + ( + group, + table_number, + person_key, + person["display_name"], + index, + timestamp, + timestamp, + ), + ) + row = self._open_group_blackjack(group) + if row is None: + raise LocalCommandError("21点牌桌创建失败") + data = dict(row) + data["status"] = "started" + data["max_players"] = max_players + data["players"] = self._group_blackjack_players( + group, table_number + ) + response = render(data) + timestamp = utc_now() + self._store_player_action( + group, event_id, person_key, command, response, timestamp + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + + def join_group_blackjack( + self, + group: str, + sender: str, + event_id: str, + command: str, + *, + max_players: int, + render: Callable[[dict[str, Any]], str], + ) -> str: + person = self._player_identity(group, sender) + person_key = str(person["person_key"]) + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + replay = self._player_action_replay( + group, event_id, person_key, command + ) + if replay is not None: + self.connection.execute("COMMIT") + return replay + row = self._open_group_blackjack(group) + if row is None: + data: dict[str, Any] = {"status": "no_table"} + else: + table_number = int(row["table_number"]) + players = self._group_blackjack_players( + group, table_number + ) + existing = self._blackjack_entry(players, person_key) + if existing is not None: + data = { + "status": "already_joined", + "table_number": table_number, + "players": players, + "display_name": person["display_name"], + } + elif str(row["state"]) != "joining": + data = { + "status": "closed", + "table_number": table_number, + "state": str(row["state"]), + "players": players, + } + elif len(players) >= max_players: + data = { + "status": "full", + "table_number": table_number, + "players": players, + "max_players": max_players, + } + else: + account = self._load_player_account(group, person_key) + if account.get("game"): + data = { + "status": "private_game", + "display_name": person["display_name"], + } + else: + timestamp = utc_now() + index = next_game_index(account, "blackjack") + self._save_player_account( + group, person_key, account, timestamp + ) + seat_order = max( + int(player["seat_order"]) + for player in players + ) + 1 + self.connection.execute( + """ + INSERT INTO group_blackjack_players ( + group_fingerprint, table_number, + person_key, display_name, seat_order, + player_game_index, bet, hand_json, state, + outcome, payout, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 0, '[]', + 'joined', '', 0, ?, ?) + """, + ( + group, + table_number, + person_key, + person["display_name"], + seat_order, + index, + timestamp, + timestamp, + ), + ) + self.connection.execute( + """ + UPDATE group_blackjack_tables + SET updated_at = ? + WHERE group_fingerprint = ? + AND table_number = ? + """, + (timestamp, group, table_number), + ) + data = { + "status": "joined", + "table_number": table_number, + "display_name": person["display_name"], + "players": self._group_blackjack_players( + group, table_number + ), + } + response = render(data) + timestamp = utc_now() + self._store_player_action( + group, event_id, person_key, command, response, timestamp + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + + def begin_group_blackjack_betting( + self, + group: str, + sender: str, + event_id: str, + command: str, + render: Callable[[dict[str, Any]], str], + ) -> str: + person = self._player_identity(group, sender) + person_key = str(person["person_key"]) + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + replay = self._player_action_replay( + group, event_id, person_key, command + ) + if replay is not None: + self.connection.execute("COMMIT") + return replay + row = self._open_group_blackjack(group) + if row is None: + data: dict[str, Any] = {"status": "no_table"} + elif str(row["host_person_key"]) != person_key: + data = { + "status": "not_host", + "host_display_name": str(row["host_display_name"]), + } + elif str(row["state"]) != "joining": + data = { + "status": "not_joining", + "state": str(row["state"]), + "table_number": int(row["table_number"]), + } + else: + table_number = int(row["table_number"]) + players = self._group_blackjack_players( + group, table_number + ) + if len(players) < 2: + data = { + "status": "not_enough", + "table_number": table_number, + "players": players, + } + else: + timestamp = utc_now() + first = players[0] + self.connection.execute( + """ + UPDATE group_blackjack_tables + SET state = 'betting', turn_person_key = ?, + updated_at = ? + WHERE group_fingerprint = ? + AND table_number = ? + """, + ( + first["person_key"], + timestamp, + group, + table_number, + ), + ) + data = { + "status": "betting_started", + "table_number": table_number, + "players": players, + "prompt_player": first, + } + response = render(data) + timestamp = utc_now() + self._store_player_action( + group, event_id, person_key, command, response, timestamp + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + + def group_blackjack_status( + self, + group: str, + sender: str, + event_id: str, + command: str, + render: Callable[[dict[str, Any]], str], + ) -> str: + person = self._player_identity(group, sender) + person_key = str(person["person_key"]) + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + replay = self._player_action_replay( + group, event_id, person_key, command + ) + if replay is not None: + self.connection.execute("COMMIT") + return replay + row = self._open_group_blackjack(group) + if row is None: + data: dict[str, Any] = {"status": "no_table"} + else: + table_number = int(row["table_number"]) + data = dict(row) + data["status"] = "active" + data["players"] = self._group_blackjack_players( + group, table_number + ) + data["dealer"] = self._blackjack_cards(row["dealer_json"]) + data["viewer_person_key"] = person_key + response = render(data) + timestamp = utc_now() + self._store_player_action( + group, event_id, person_key, command, response, timestamp + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + + def place_group_blackjack_bet( + self, + group: str, + sender: str, + event_id: str, + command: str, + bet: int, + deck: list[int], + render: Callable[[dict[str, Any]], str], + ) -> str: + person = self._player_identity(group, sender) + person_key = str(person["person_key"]) + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + replay = self._player_action_replay( + group, event_id, person_key, command + ) + if replay is not None: + self.connection.execute("COMMIT") + return replay + row = self._open_group_blackjack(group) + if row is None: + data: dict[str, Any] = {"status": "no_table"} + elif str(row["state"]) != "betting": + data = { + "status": "not_betting", + "state": str(row["state"]), + "table_number": int(row["table_number"]), + } + else: + table_number = int(row["table_number"]) + players = self._group_blackjack_players( + group, table_number + ) + entry = self._blackjack_entry(players, person_key) + current = self._blackjack_entry( + players, + str(row["turn_person_key"]), + ) + if entry is None: + data = { + "status": "not_player", + "table_number": table_number, + "players": players, + } + elif current is None: + raise LocalCommandError("21点牌桌轮次无效") + elif person_key != str(row["turn_person_key"]): + data = { + "status": "wrong_turn", + "table_number": table_number, + "prompt_player": current, + "players": players, + } + else: + account = self._load_player_account(group, person_key) + if bet > int(account["balance"]): + data = { + "status": "insufficient", + "table_number": table_number, + "display_name": person["display_name"], + "balance": int(account["balance"]), + "bet": bet, + } + else: + timestamp = utc_now() + account["balance"] -= bet + self._save_player_account( + group, person_key, account, timestamp + ) + self.connection.execute( + """ + UPDATE group_blackjack_players + SET bet = ?, state = 'bet', + updated_at = ? + WHERE group_fingerprint = ? + AND table_number = ? + AND person_key = ? + """, + ( + bet, + timestamp, + group, + table_number, + person_key, + ), + ) + players = self._group_blackjack_players( + group, table_number + ) + pending = [ + player for player in players + if int(player["bet"]) <= 0 + ] + if pending: + prompt = pending[0] + self.connection.execute( + """ + UPDATE group_blackjack_tables + SET turn_person_key = ?, updated_at = ? + WHERE group_fingerprint = ? + AND table_number = ? + """, + ( + prompt["person_key"], + timestamp, + group, + table_number, + ), + ) + data = { + "status": "bet_accepted", + "table_number": table_number, + "display_name": person["display_name"], + "bet": bet, + "balance": int(account["balance"]), + "players": players, + "prompt_player": prompt, + } + else: + if len(deck) < len(players) * 2 + 2: + raise LocalCommandError("21点牌堆无效") + hands: dict[str, list[int]] = { + str(player["person_key"]): [] + for player in players + } + cursor = 0 + for player in players: + hands[str(player["person_key"])].append( + deck[cursor] + ) + cursor += 1 + dealer = [deck[cursor]] + cursor += 1 + for player in players: + hands[str(player["person_key"])].append( + deck[cursor] + ) + cursor += 1 + dealer.append(deck[cursor]) + cursor += 1 + remaining = deck[cursor:] + for player in players: + key = str(player["person_key"]) + hand = hands[key] + state = ( + "stood" + if hand_value(hand) == 21 + else "playing" + ) + player["hand"] = hand + player["state"] = state + self.connection.execute( + """ + UPDATE group_blackjack_players + SET hand_json = ?, state = ?, + updated_at = ? + WHERE group_fingerprint = ? + AND table_number = ? + AND person_key = ? + """, + ( + canonical_json(hand), + state, + timestamp, + group, + table_number, + key, + ), + ) + next_player = self._blackjack_next_player( + players + ) + self.connection.execute( + """ + UPDATE group_blackjack_tables + SET state = 'playing', dealer_json = ?, + deck_json = ?, turn_person_key = ?, + updated_at = ? + WHERE group_fingerprint = ? + AND table_number = ? + """, + ( + canonical_json(dealer), + canonical_json(remaining), + ( + str(next_player["person_key"]) + if next_player is not None + else "" + ), + timestamp, + group, + table_number, + ), + ) + table = self._open_group_blackjack(group) + if table is None: + raise LocalCommandError("21点牌桌状态无效") + if next_player is None: + data = self._settle_group_blackjack_locked( + group, + table, + players, + timestamp, + ) + else: + data = { + "status": "dealt", + "table_number": table_number, + "display_name": person["display_name"], + "bet": bet, + "balance": int(account["balance"]), + "players": players, + "dealer": dealer, + "prompt_player": next_player, + } + response = render(data) + timestamp = utc_now() + self._store_player_action( + group, event_id, person_key, command, response, timestamp + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + + def play_group_blackjack( + self, + group: str, + sender: str, + event_id: str, + command: str, + action: str, + render: Callable[[dict[str, Any]], str], + ) -> str: + person = self._player_identity(group, sender) + person_key = str(person["person_key"]) + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + replay = self._player_action_replay( + group, event_id, person_key, command + ) + if replay is not None: + self.connection.execute("COMMIT") + return replay + row = self._open_group_blackjack(group) + if row is None: + data: dict[str, Any] = {"status": "no_table"} + elif str(row["state"]) != "playing": + data = { + "status": "not_playing", + "state": str(row["state"]), + "table_number": int(row["table_number"]), + } + else: + table_number = int(row["table_number"]) + players = self._group_blackjack_players( + group, table_number + ) + entry = self._blackjack_entry(players, person_key) + current = self._blackjack_entry( + players, + str(row["turn_person_key"]), + ) + if entry is None: + data = { + "status": "not_player", + "table_number": table_number, + "players": players, + } + elif current is None: + raise LocalCommandError("21点牌桌轮次无效") + elif person_key != str(row["turn_person_key"]): + data = { + "status": "wrong_turn", + "table_number": table_number, + "prompt_player": current, + "players": players, + } + elif str(entry["state"]) != "playing": + data = { + "status": "not_actionable", + "table_number": table_number, + "players": players, + } + else: + timestamp = utc_now() + deck = self._blackjack_cards(row["deck_json"]) + dealer = self._blackjack_cards(row["dealer_json"]) + state = "stood" + drawn_card: int | None = None + if action == "hit": + if not deck: + raise LocalCommandError("21点牌堆已经抽空") + drawn_card = deck.pop(0) + entry["hand"].append(drawn_card) + value = hand_value(entry["hand"]) + if value > 21: + state = "busted" + elif value == 21: + state = "stood" + else: + state = "playing" + entry["state"] = state + self.connection.execute( + """ + UPDATE group_blackjack_players + SET hand_json = ?, state = ?, updated_at = ? + WHERE group_fingerprint = ? AND table_number = ? + AND person_key = ? + """, + ( + canonical_json(entry["hand"]), + state, + timestamp, + group, + table_number, + person_key, + ), + ) + if action == "hit": + self.connection.execute( + """ + UPDATE group_blackjack_tables + SET deck_json = ?, updated_at = ? + WHERE group_fingerprint = ? + AND table_number = ? + """, + ( + canonical_json(deck), + timestamp, + group, + table_number, + ), + ) + players = self._group_blackjack_players( + group, table_number + ) + entry = self._blackjack_entry(players, person_key) + if entry is None: + raise LocalCommandError("21点牌桌玩家状态无效") + if state == "playing": + data = { + "status": "hit", + "table_number": table_number, + "player": entry, + "drawn_card": drawn_card, + "dealer": dealer, + "prompt_player": entry, + "players": players, + } + else: + next_player = self._blackjack_next_player( + players, + after_order=int(entry["seat_order"]), + ) + if next_player is not None: + self.connection.execute( + """ + UPDATE group_blackjack_tables + SET turn_person_key = ?, updated_at = ? + WHERE group_fingerprint = ? + AND table_number = ? + """, + ( + next_player["person_key"], + timestamp, + group, + table_number, + ), + ) + data = { + "status": ( + "busted" + if state == "busted" + else "stood" + ), + "table_number": table_number, + "player": entry, + "drawn_card": drawn_card, + "dealer": dealer, + "players": players, + "prompt_player": next_player, + } + else: + self.connection.execute( + """ + UPDATE group_blackjack_tables + SET deck_json = ?, updated_at = ? + WHERE group_fingerprint = ? + AND table_number = ? + """, + ( + canonical_json(deck), + timestamp, + group, + table_number, + ), + ) + table = self._open_group_blackjack(group) + if table is None: + raise LocalCommandError("21点牌桌状态无效") + data = self._settle_group_blackjack_locked( + group, + table, + players, + timestamp, + ) + response = render(data) + timestamp = utc_now() + self._store_player_action( + group, event_id, person_key, command, response, timestamp + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + + def cancel_group_blackjack( + self, + group: str, + sender: str, + event_id: str, + command: str, + render: Callable[[dict[str, Any]], str], + ) -> str: + person = self._player_identity(group, sender) + person_key = str(person["person_key"]) + with self.lock: + self.connection.execute("BEGIN IMMEDIATE") + try: + replay = self._player_action_replay( + group, event_id, person_key, command + ) + if replay is not None: + self.connection.execute("COMMIT") + return replay + row = self._open_group_blackjack(group) + if row is None: + data: dict[str, Any] = {"status": "no_table"} + elif str(row["host_person_key"]) != person_key: + data = { + "status": "not_host", + "host_display_name": str(row["host_display_name"]), + } + else: + table_number = int(row["table_number"]) + players = self._group_blackjack_players( + group, table_number + ) + timestamp = utc_now() + refunded = 0 + for player in players: + bet = int(player["bet"]) + if bet <= 0: + continue + key = str(player["person_key"]) + account = self._load_player_account(group, key) + account["balance"] += bet + append_game_record( + account, + kind="blackjack", + index=int(player["player_game_index"]), + outcome="牌桌取消", + stake=bet, + payout=bet, + summary="主持人取消,多人21点下注已全额退回", + ) + self._save_player_account( + group, key, account, timestamp + ) + refunded += 1 + self.connection.execute( + """ + UPDATE group_blackjack_tables + SET state = 'cancelled', turn_person_key = '', + updated_at = ? + WHERE group_fingerprint = ? AND table_number = ? + """, + (timestamp, group, table_number), + ) + data = { + "status": "cancelled", + "table_number": table_number, + "players": players, + "refunded": refunded, + } + response = render(data) + timestamp = utc_now() + self._store_player_action( + group, event_id, person_key, command, response, timestamp + ) + self.connection.execute("COMMIT") + return response + except BaseException: + self.connection.execute("ROLLBACK") + raise + def player_game_history( self, group: str, @@ -3773,6 +4859,133 @@ class Ledger: ) return records[: max(1, min(int(limit), 20))] + def player_taffy_coin_stats( + self, + group: str, + *, + limit: int = 8, + ) -> 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 + 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: + labels.setdefault( + str(row["person_key"]), + person_display_label(row["display_name"]), + ) + for row in observation_rows: + labels.setdefault( + str(row["person_key"]), + person_display_label(row["display_name"]), + ) + total_stake = 0 + total_payout = 0 + total_records = 0 + players: 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) + stake = 0 + payout = 0 + records = 0 + for entry in account["game_history"]: + try: + entry_stake = int(entry.get("stake", 0)) + entry_payout = int(entry.get("payout", 0)) + except (TypeError, ValueError): + continue + stake += entry_stake + payout += entry_payout + records += 1 + if not records: + continue + total_stake += stake + total_payout += payout + total_records += records + players.append( + { + "display_name": labels.get( + person_key, + f"玩家 {person_key[:8]}", + ), + "stake": stake, + "payout": payout, + "taffy_net": stake - payout, + "records": records, + } + ) + players.sort( + key=lambda item: ( + -int(item["taffy_net"]), + str(item["display_name"]).casefold(), + ) + ) + return { + "stake": total_stake, + "payout": total_payout, + "taffy_net": total_stake - total_payout, + "records": total_records, + "players": players[: max(1, min(int(limit), 20))], + } + def transfer_player_coins( self, group: str, diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_taffy.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_taffy.py new file mode 100644 index 0000000..08974dd --- /dev/null +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_taffy.py @@ -0,0 +1,45 @@ +"""Generated reviewed adapter for coin-taffy.""" + +from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext + + +PROFILE = 'coin-taffy' +AUTHOR = 'jwebot' +DESCRIPTION = 'Taffy金币' +HELP_ENTRIES = ('/Taffy金币',) +COMMANDS = ('/Taffy金币', '/taffycoins') + + +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/adapters/context_summary.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py index ec5e678..95e0da4 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py @@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext PROFILE = 'context-summary' AUTHOR = 'official' -DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文' +DESCRIPTION = '上下文总结' HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',) COMMANDS = ('/上下文', '/context') diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py index a568ecf..d783010 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py @@ -6,7 +6,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext PROFILE = 'single-player-games' AUTHOR = 'jwebot' DESCRIPTION = '金币游戏' -HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games') +HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games') COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') 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 d9cec75..df06a80 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py @@ -46,6 +46,8 @@ LOCAL_COMMAND_ALIASES = { "金币排行榜": "coin_ranking", "大富翁榜": "coin_ranking", "richlist": "coin_ranking", + "Taffy金币": "taffy_coins", + "taffycoins": "taffy_coins", "转账": "transfer", "transfer": "transfer", "贷款": "loan", @@ -143,7 +145,7 @@ LOCAL_EXTENSIONS = ( LocalExtensionDescriptor( extension_id="context-summary", author=OFFICIAL_AUTHOR, - description="提取当前群最近100、200或300条本地文字的上下文", + description="上下文总结", help_entries=("/上下文 [100|200|300];/context [100|200|300]",), command_kinds=("context_summary",), ), @@ -155,8 +157,7 @@ LOCAL_EXTENSIONS = ( "/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", - "/21点 开始 [1~500];/blackjack start", - "/21点 要牌|停牌|状态|结束;/blackjack", + "/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200", "/游戏帮助;/games", ), command_kinds=( @@ -182,6 +183,13 @@ LOCAL_EXTENSIONS = ( help_entries=("/大富翁榜",), command_kinds=("coin_ranking",), ), + LocalExtensionDescriptor( + extension_id="coin-taffy", + author=JWEBOT_AUTHOR, + description="Taffy金币", + help_entries=("/Taffy金币",), + command_kinds=("taffy_coins",), + ), LocalExtensionDescriptor( extension_id="coin-loan", 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 efe7569..7ba3ae4 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 @@ -157,6 +157,7 @@ class LocalCommandEngine: if kind in { "wallet", "coin_ranking", + "taffy_coins", "transfer", "loan", "repay", 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 2b64e3b..2d08e39 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 @@ -16,6 +16,9 @@ MAX_LOTTERY_BET = 500 DEFAULT_LOTTERY_BET = 10 MIN_BLACKJACK_BET = 1 MAX_BLACKJACK_BET = 500 +MIN_BLACKJACK_TABLE_BET = 1 +MAX_BLACKJACK_TABLE_BET = 10_000 +MAX_BLACKJACK_TABLE_PLAYERS = 8 MIN_TRANSFER_AMOUNT = 1 MAX_TRANSFER_AMOUNT = 100_000 MIN_LOAN_AMOUNT = 1 @@ -45,6 +48,10 @@ BLACKJACK_COMBINED_ACTION = re.compile( r"^(?:开始|start)(?P\d{1,3})?" r"(?:要牌|hit|停牌|stand)$" ) +BLACKJACK_TABLE_BET = re.compile( + r"^(?:放|下注|bet)(?P\d{1,5})$", + re.IGNORECASE, +) TRANSFER_MARKED = re.compile( r"^(?:给|to)?\s*(?P.+?)\s*" r"(?:金额|金币|coin|coins|:|:)\s*(?P\d{1,6})$", @@ -161,6 +168,9 @@ class PlayerGames: "看到牌后再单独发送 /21点 要牌|停牌|状态|结束" "(/blackjack hit|stand|status|end);" "每人最多一局,10分钟无操作视为认输,结束不退下注。\n" + "多人21点:/21点 多人 启动 创建牌桌;/21点 加入;" + "主持人 /21点 多人 开始 后按提示 /21点 放200" + "(1~10000);轮到自己 /21点 要牌|停牌。\n" "21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。" "以上均含本金;虚拟金币不能充值或兑换。" ) @@ -177,6 +187,8 @@ class PlayerGames: return self.group_lottery(row, arguments) if kind == "coin_ranking": return self.coin_ranking(row, arguments) + if kind == "taffy_coins": + return self.taffy_coins(row, arguments) if kind == "transfer": return self.transfer(row, arguments) if kind in {"loan", "repay"}: @@ -188,6 +200,15 @@ class PlayerGames: return "用法:/抽奖 [2~100金币](/draw),默认10金币" bet = int(arguments or 10) elif kind == "blackjack": + table_command = self.parse_blackjack_table_command(arguments) + if table_command is not None: + table_action, table_bet = table_command + return self.group_blackjack( + row, + table_action, + table_bet, + arguments, + ) compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态" start = BLACKJACK_START.fullmatch(compacted) if start is not None: @@ -209,6 +230,28 @@ class PlayerGames: "用法:先 /21点 开始 [1~500](金额是本局下注,默认50);" "开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略" ) + group = str(row["group_fingerprint"]) + if ( + action in {"hit", "stand", "status"} + and self.ledger.has_active_group_blackjack(group) + ): + return self.group_blackjack(row, action, 0, arguments) + if ( + action == "start" + and self.ledger.has_active_group_blackjack(group) + ): + return ( + "当前已有Taffy的21点多人牌桌;" + "发送 /21点 状态 查看,或等待本桌结束" + ) + if ( + action == "end" + and self.ledger.has_active_group_blackjack(group) + ): + return ( + "当前是多人21点牌桌;轮到你时请 /21点 停牌," + "主持人可 /21点 多人 取消" + ) else: bet = 0 if kind == "prize_draw" and not MIN_DRAW_BET <= bet <= MAX_DRAW_BET: @@ -341,6 +384,451 @@ class PlayerGames: ) return "\n".join(lines) + def taffy_coins(self, row: dict[str, Any], arguments: str) -> str: + value = arguments.strip() + if value and not re.fullmatch(r"\d{1,2}", value): + return "用法:/Taffy金币 [人数]" + limit = int(value or 8) + if not 1 <= limit <= 20: + return "Taffy金币人数必须为 1~20" + stats = self.ledger.player_taffy_coin_stats( + str(row["group_fingerprint"]), + limit=limit, + ) + if int(stats["records"]) == 0: + return "Taffy金币:当前群暂无已结算游戏记录" + net = int(stats["taffy_net"]) + result = f"Taffy累计赢 {net} 金币" if net >= 0 else f"Taffy累计倒贴 {-net} 金币" + lines = [ + ( + f"Taffy金币:已结算 {stats['records']} 局;" + f"玩家总下注 {stats['stake']},总返还 {stats['payout']};" + f"{result}" + ) + ] + if stats["players"]: + lines.append("最大上贡者排行(含负数):") + else: + lines.append("最大上贡者排行:暂无已结算玩家明细") + for index, record in enumerate(stats["players"], start=1): + player_net = int(record["taffy_net"]) + if player_net > 0: + verdict = f"上贡 {player_net}" + elif player_net < 0: + verdict = f"从Taffy赢走 {-player_net}" + else: + verdict = "打平 0" + lines.append( + f"{index}. {record['display_name']}:{verdict};" + f"下注 {record['stake']},返还 {record['payout']}," + f"{record['records']} 局" + ) + return "\n".join(lines) + + @staticmethod + def parse_blackjack_table_command( + arguments: str, + ) -> tuple[str, int] | None: + compacted = re.sub(r"\s+", "", arguments.casefold()) + if not compacted: + return None + if compacted in {"多人", "牌桌", "table", "multiplayer"}: + return "help", 0 + if compacted in { + "多人启动", + "多人开桌", + "多人创建", + "牌桌启动", + "牌桌开桌", + "tablestart", + "multiplayerstart", + }: + return "start", 0 + if compacted in {"加入", "join"}: + return "join", 0 + if compacted in { + "多人开始", + "多人发牌", + "开始多人", + "开桌", + "发牌", + "tabledeal", + "multiplayerdeal", + }: + return "begin", 0 + if compacted in { + "多人取消", + "牌桌取消", + "取消牌桌", + "tablecancel", + "multiplayercancel", + }: + return "cancel", 0 + bet = BLACKJACK_TABLE_BET.fullmatch(compacted) + if bet is not None: + return "bet", int(bet["bet"]) + return None + + @staticmethod + def blackjack_players_text(players: list[dict[str, Any]]) -> str: + names = [str(player["display_name"]) for player in players] + return "、".join(names) if names else "暂无" + + @staticmethod + def blackjack_hand_text(hand: list[int]) -> str: + if not hand: + return "未发牌" + return ( + f"{' '.join(card_label(card) for card in hand)}" + f"({hand_value(hand)}点)" + ) + + @staticmethod + def blackjack_prompt(player: dict[str, Any]) -> str: + return ( + f"请{player['display_name']} 放自己的筹码\n" + "输入/21点 放200(1~10000)" + ) + + def group_blackjack( + self, + row: dict[str, Any], + action: str, + amount: int, + arguments: str, + ) -> str: + group = str(row["group_fingerprint"]) + sender = str(row["sender"]) + event_id = str(row["event_id"]) + command = f"blackjack-table:{arguments}" + if action == "help": + return ( + "多人21点用法:/21点 多人 启动;/21点 加入;" + "主持人 /21点 多人 开始;按提示 /21点 放200" + "(1~10000);轮到自己 /21点 要牌 或 /21点 停牌" + ) + if action == "start": + return self.ledger.start_group_blackjack( + group, + sender, + event_id, + command, + max_players=MAX_BLACKJACK_TABLE_PLAYERS, + render=self.render_blackjack_table_start, + ) + if action == "join": + return self.ledger.join_group_blackjack( + group, + sender, + event_id, + command, + max_players=MAX_BLACKJACK_TABLE_PLAYERS, + render=self.render_blackjack_table_join, + ) + if action == "begin": + return self.ledger.begin_group_blackjack_betting( + group, + sender, + event_id, + command, + self.render_blackjack_table_begin, + ) + if action == "cancel": + return self.ledger.cancel_group_blackjack( + group, + sender, + event_id, + command, + self.render_blackjack_table_cancel, + ) + if action == "status": + return self.ledger.group_blackjack_status( + group, + sender, + event_id, + command, + self.render_blackjack_table_status, + ) + if action == "bet": + if not MIN_BLACKJACK_TABLE_BET <= amount <= MAX_BLACKJACK_TABLE_BET: + return "21点多人牌桌筹码必须为 1~10000 的整数金币" + deck = list(range(52)) + self.rng.shuffle(deck) + return self.ledger.place_group_blackjack_bet( + group, + sender, + event_id, + command, + amount, + deck, + self.render_blackjack_table_bet, + ) + if action in {"hit", "stand"}: + return self.ledger.play_group_blackjack( + group, + sender, + event_id, + command, + action, + self.render_blackjack_table_action, + ) + raise ValueError(f"unsupported blackjack table action: {action}") + + def render_blackjack_table_start(self, data: dict[str, Any]) -> str: + status = data["status"] + if status == "private_game": + return ( + f"{data['display_name']}:你已有单人21点," + "请先 /21点 停牌 或 /21点 结束" + ) + players = data.get("players", []) + prefix = ( + "当前已有Taffy 的黑杰克Blackjack 21点牌桌" + if status == "already_open" + else "欢迎来到Taffy 的黑杰克Blackjack 21点牌桌" + ) + return ( + f"{prefix}\n" + "输入/21点 加入 加入当前游戏!" + f"目前玩家:{self.blackjack_players_text(players)}\n" + "主持人发送 /21点 多人 开始 后," + "Taffy会按顺序喊每个人放筹码" + ) + + def render_blackjack_table_join(self, data: dict[str, Any]) -> str: + status = data["status"] + if status == "no_table": + return "当前没有21点多人牌桌;发送 /21点 多人 启动" + if status == "closed": + return "这桌已经停止加入;发送 /21点 状态 查看当前进度" + if status == "full": + return f"这桌已满,最多 {data['max_players']} 人" + if status == "private_game": + return ( + f"{data['display_name']}:你已有单人21点," + "请先 /21点 停牌 或 /21点 结束" + ) + if status == "already_joined": + return ( + f"{data['display_name']} 已经在这桌;" + f"目前玩家:{self.blackjack_players_text(data['players'])}" + ) + return ( + f"{data['display_name']} 已加入Taffy的21点牌桌;" + f"目前玩家:{self.blackjack_players_text(data['players'])}" + ) + + def render_blackjack_table_begin(self, data: dict[str, Any]) -> str: + status = data["status"] + if status == "no_table": + return "当前没有21点多人牌桌;发送 /21点 多人 启动" + if status == "not_host": + return f"只有本桌主持人 {data['host_display_name']} 可以结束加入" + if status == "not_joining": + return "这桌已经结束加入;发送 /21点 状态 查看当前进度" + if status == "not_enough": + return ( + "至少需要 2 名玩家才能开始多人21点;" + f"目前玩家:{self.blackjack_players_text(data['players'])}" + ) + return ( + "加入结束,Taffy 按顺序收筹码。\n" + f"{self.blackjack_prompt(data['prompt_player'])}" + ) + + def render_blackjack_table_status(self, data: dict[str, Any]) -> str: + if data["status"] == "no_table": + return "当前没有21点多人牌桌;发送 /21点 多人 启动" + state = str(data["state"]) + table_number = int(data["table_number"]) + players = data["players"] + if state == "joining": + return ( + f"21点牌桌第 {table_number} 桌正在加入;" + f"目前玩家:{self.blackjack_players_text(players)}\n" + "输入/21点 加入 加入当前游戏;" + "主持人 /21点 多人 开始" + ) + if state == "betting": + prompt = next( + ( + player for player in players + if str(player["person_key"]) == str(data["turn_person_key"]) + ), + None, + ) + lines = [f"21点牌桌第 {table_number} 桌正在下注:"] + for player in players: + bet = int(player["bet"]) + lines.append( + f"{player['display_name']} #{player['player_game_index']}:" + f"{bet if bet else '未放'}" + ) + if prompt is not None: + lines.append(self.blackjack_prompt(prompt)) + return "\n".join(lines) + if state == "playing": + prompt = next( + ( + player for player in players + if str(player["person_key"]) == str(data["turn_person_key"]) + ), + None, + ) + dealer = data.get("dealer", []) + dealer_text = ( + f"{card_label(dealer[0])} + 暗牌" + if dealer + else "未发牌" + ) + lines = [ + f"21点牌桌第 {table_number} 桌正在出牌;Taffy {dealer_text}" + ] + for player in players: + lines.append( + f"{player['display_name']} #{player['player_game_index']}:" + f"{self.blackjack_hand_text(player['hand'])};" + f"下注 {player['bet']};{player['state']}" + ) + if prompt is not None: + lines.append( + f"请{prompt['display_name']} 操作:" + "/21点 要牌 或 /21点 停牌" + ) + return "\n".join(lines) + return "当前没有进行中的21点多人牌桌" + + def render_blackjack_table_bet(self, data: dict[str, Any]) -> str: + status = data["status"] + if status == "no_table": + return "当前没有21点多人牌桌;发送 /21点 多人 启动" + if status == "not_betting": + return "现在不是放筹码阶段;发送 /21点 状态 查看当前进度" + if status == "not_player": + return "你不在当前21点牌桌;下一桌可发送 /21点 加入" + if status == "wrong_turn": + return self.blackjack_prompt(data["prompt_player"]) + if status == "insufficient": + return ( + f"{data['display_name']}:金币不足,余额 {data['balance']}," + f"本次需要 {data['bet']}" + ) + if status == "bet_accepted": + return ( + f"{data['display_name']} 放下 {data['bet']};" + f"金币 {data['balance']}\n" + f"{self.blackjack_prompt(data['prompt_player'])}" + ) + if status == "dealt": + return self.render_blackjack_table_dealt(data) + if status == "settled": + return self.render_blackjack_table_settlement(data) + raise ValueError(f"unsupported blackjack bet status: {status}") + + def render_blackjack_table_dealt(self, data: dict[str, Any]) -> str: + dealer = data.get("dealer", []) + dealer_text = ( + f"{card_label(dealer[0])} + 暗牌" + if dealer + else "未发牌" + ) + lines = [ + ( + f"{data['display_name']} 放下 {data['bet']};" + "全部筹码已确认。Taffy 发牌。" + ), + f"Taffy 明牌 {dealer_text}", + ] + for player in data["players"]: + lines.append( + f"{player['display_name']} #{player['player_game_index']}:" + f"{self.blackjack_hand_text(player['hand'])};" + f"下注 {player['bet']}" + ) + prompt = data["prompt_player"] + lines.append( + f"请{prompt['display_name']} 操作:/21点 要牌 或 /21点 停牌" + ) + return "\n".join(lines) + + def render_blackjack_table_action(self, data: dict[str, Any]) -> str: + status = data["status"] + if status == "no_table": + return "当前没有21点多人牌桌;发送 /21点 多人 启动" + if status == "not_playing": + return "现在不是出牌阶段;发送 /21点 状态 查看当前进度" + if status == "not_player": + return "你不在当前21点牌桌;下一桌可发送 /21点 加入" + if status == "wrong_turn": + prompt = data["prompt_player"] + return ( + f"现在轮到 {prompt['display_name']};" + "/21点 要牌 或 /21点 停牌" + ) + if status == "not_actionable": + return "你这一手已经结束;等待本桌结算" + if status == "settled": + return self.render_blackjack_table_settlement(data) + player = data["player"] + lines: list[str] = [] + if status == "hit": + lines.append( + f"{player['display_name']} 要牌,拿到 " + f"{card_label(data['drawn_card'])};" + f"当前 {self.blackjack_hand_text(player['hand'])}" + ) + elif status == "busted": + lines.append( + f"{player['display_name']} 要牌,拿到 " + f"{card_label(data['drawn_card'])};" + f"{self.blackjack_hand_text(player['hand'])} 爆牌" + ) + elif status == "stood": + lines.append( + f"{player['display_name']} 停牌;" + f"{self.blackjack_hand_text(player['hand'])}" + ) + else: + raise ValueError(f"unsupported blackjack action status: {status}") + prompt = data["prompt_player"] + lines.append( + f"请{prompt['display_name']} 操作:/21点 要牌 或 /21点 停牌" + ) + return "\n".join(lines) + + def render_blackjack_table_settlement(self, data: dict[str, Any]) -> str: + dealer = data.get("dealer", []) + dealer_text = self.blackjack_hand_text(dealer) + lines = [ + ( + "Taffy 的黑杰克Blackjack 21点牌桌" + f"第 {data['table_number']} 桌结算:" + ), + f"Taffy 庄家 {dealer_text}", + "这一桌每个人的变化:", + ] + for player in data["players"]: + bet = int(player["bet"]) + payout = int(player["payout"]) + lines.append( + f"{player['display_name']} #{player['player_game_index']}:" + f"{player['outcome']};牌 {self.blackjack_hand_text(player['hand'])};" + f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};" + f"金币 {player['balance']}" + ) + return "\n".join(lines) + + def render_blackjack_table_cancel(self, data: dict[str, Any]) -> str: + status = data["status"] + if status == "no_table": + return "当前没有21点多人牌桌" + if status == "not_host": + return f"只有本桌主持人 {data['host_display_name']} 可以取消" + return ( + f"21点牌桌第 {data['table_number']} 桌已取消;" + f"{data['refunded']} 人的下注已退回" + ) + @staticmethod def parse_transfer( arguments: str, diff --git a/tests/test_official_extension_inventory.py b/tests/test_official_extension_inventory.py index bc41433..b1de4da 100644 --- a/tests/test_official_extension_inventory.py +++ b/tests/test_official_extension_inventory.py @@ -92,6 +92,7 @@ class LocalExtensionInventoryTests(unittest.TestCase): "coin": JWEBOT_AUTHOR, "coin-transfer": JWEBOT_AUTHOR, "coin-ranking": JWEBOT_AUTHOR, + "coin-taffy": JWEBOT_AUTHOR, "coin-loan": JWEBOT_AUTHOR, "random-number": JWEBOT_AUTHOR, "draw-lot": JWEBOT_AUTHOR, diff --git a/tests/test_release_063.py b/tests/test_release_063.py index 61d0425..d75bf65 100644 --- a/tests/test_release_063.py +++ b/tests/test_release_063.py @@ -176,6 +176,56 @@ class Release063GameTests(unittest.TestCase): self.assertIn("下注 500,返还 500", history) self.assertIn("1~500", self.command("/21点开始501")) + def test_multiplayer_blackjack_table_guides_turns_and_settles(self): + self.assertIn("黑杰克Blackjack 21点牌桌", self.command("/21点 多人 启动")) + self.assertIn("目前玩家:alice", self.command("/21点 状态")) + self.assertIn("bob 已加入", self.command("/21点 加入", sender="Bob")) + self.assertIn("请alice 放自己的筹码", self.command("/21点 多人 开始")) + + alice_bet = self.command("/21点 放200", event_id="alice-table-bet") + replay = self.command( + "/21点 放200", + sender="a1", + event_id="alice-table-bet", + ) + self.assertEqual(replay, alice_bet) + self.assertIn("请bob 放自己的筹码", alice_bet) + self.assertEqual(self.state()["balance"], 800) + + def table_deck(cards): + first = [12, 8, 9, 24, 19, 32, 25] + cards[:] = first + [card for card in cards if card not in first] + + with mock.patch.object( + self.engine.local.players.rng, + "shuffle", + side_effect=table_deck, + ): + dealt = self.command("/21点 放100", sender="Bob") + self.assertIn("全部筹码已确认", dealt) + self.assertIn("alice #1:K Q(20点);下注 200", dealt) + self.assertIn("bob #1:9 7(16点);下注 100", dealt) + self.assertIn("请alice 操作", dealt) + self.assertEqual(self.state("Bob")["balance"], 900) + + alice_stand = self.command("/21点 停牌") + self.assertIn("alice 停牌", alice_stand) + self.assertIn("请bob 操作", alice_stand) + + settled = self.command("/21点 要牌", sender="Bob") + self.assertIn("这一桌每个人的变化", settled) + self.assertIn("alice #1:你赢了", settled) + self.assertIn("下注 200,返还 400,净变化 +200;金币 1200", settled) + self.assertIn("bob #1:你输了", settled) + self.assertIn("下注 100,返还 0,净变化 -100;金币 900", settled) + self.assertEqual(self.state()["balance"], 1200) + self.assertEqual(self.state("Bob")["balance"], 900) + self.assertIn("alice #1:21点 你赢了", self.command("/游戏记录")) + self.assertIn( + "bob #1:21点 你输了", + self.command("/游戏记录", sender="Bob"), + ) + def test_prize_draw_has_indexed_history(self): with mock.patch.object( self.engine.local.players.rng, @@ -262,6 +312,29 @@ class Release063GameTests(unittest.TestCase): self.assertNotIn("Dave", ranking) self.assertIn("金币排行人数必须为 1~20", self.command("/金币排行 0")) + def test_taffy_coin_stats_sums_all_game_history(self): + with mock.patch.object( + self.engine.local.players.rng, + "randrange", + return_value=99, + ): + self.command("/抽奖10") + with mock.patch.object( + self.engine.local.players.rng, + "randrange", + return_value=0, + ): + self.command("/抽奖10", sender="Bob") + + stats = self.command("/Taffy金币") + + self.assertIn("玩家总下注 20,总返还 50", stats) + self.assertIn("Taffy累计倒贴 30 金币", stats) + self.assertIn("最大上贡者排行(含负数):", stats) + self.assertIn("alice:上贡 10", stats) + self.assertIn("bob:从Taffy赢走 40", stats) + self.assertIn("Taffy金币人数必须为 1~20", self.command("/Taffy金币 0")) + def test_open_round_and_history_survive_restart(self): root = Path(self.temp.name) self.command("/彩票开始")