feat: add multiplayer lottery and game records
check / local-gate (push) Failing after 1m35s

This commit is contained in:
JWeBot Contributors
2026-10-15 13:57:32 +08:00
parent 0731e5cebb
commit f07ae653c7
29 changed files with 1463 additions and 128 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-config"
version = "0.6.2a0+1015"
version = "0.6.3a0+1015"
description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9"
dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile",
]
__version__ = "0.6.2a0+1015"
__version__ = "0.6.3a0+1015"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-extension-host"
version = "0.6.2a0+1015"
version = "0.6.3a0+1015"
description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==0.6.2a0+1015",
"jwebot-kernel==0.6.2a0+1015",
"jwebot-sdk==0.6.3a0+1015",
"jwebot-kernel==0.6.3a0+1015",
]
[project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request",
]
__version__ = "0.6.2a0+1015"
__version__ = "0.6.3a0+1015"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-kernel"
version = "0.6.2a0+1015"
version = "0.6.3a0+1015"
description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-config==0.6.2a0+1015",
"jwebot-sdk==0.6.2a0+1015",
"jwebot-official-extensions==0.6.2a0+1015",
"jwebot-config==0.6.3a0+1015",
"jwebot-sdk==0.6.3a0+1015",
"jwebot-official-extensions==0.6.3a0+1015",
"tomli>=2; python_version < '3.11'",
]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature",
]
__version__ = "0.6.2a0+1015"
__version__ = "0.6.3a0+1015"
@@ -68,6 +68,11 @@ from jwebot_official_extensions.steam import (
SteamClient,
SteamUnavailable,
)
from jwebot_official_extensions.player_games import (
append_game_record,
next_game_index,
normalize_player_account,
)
from jwebot_sdk import Event, PlanKind
@@ -1026,6 +1031,45 @@ class Ledger:
created_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, event_id)
);
CREATE TABLE IF NOT EXISTS group_lottery_rounds (
group_fingerprint TEXT NOT NULL,
round_number INTEGER NOT NULL,
state TEXT NOT NULL,
host_person_key TEXT NOT NULL,
host_display_name TEXT NOT NULL,
drawn_numbers_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, round_number)
);
CREATE TABLE IF NOT EXISTS group_lottery_entries (
group_fingerprint TEXT NOT NULL,
round_number INTEGER NOT NULL,
person_key TEXT NOT NULL,
display_name TEXT NOT NULL,
player_game_index INTEGER NOT NULL,
numbers_json TEXT NOT NULL,
stake INTEGER NOT NULL,
matches INTEGER NOT NULL DEFAULT 0,
payout INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (
group_fingerprint, round_number, person_key
),
FOREIGN KEY (group_fingerprint, round_number)
REFERENCES group_lottery_rounds (
group_fingerprint, round_number
)
ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS group_lottery_one_open
ON group_lottery_rounds(group_fingerprint)
WHERE state = 'open';
CREATE INDEX IF NOT EXISTS group_lottery_entries_round
ON group_lottery_entries(
group_fingerprint, round_number, created_at, person_key
);
CREATE INDEX IF NOT EXISTS events_group_created
ON events(group_fingerprint, created_at);
CREATE INDEX IF NOT EXISTS outbox_due
@@ -2983,49 +3027,606 @@ class Ledger:
})
return list(people.values())
def _player_identity(self, group: str, sender: str) -> dict[str, str]:
if not group or not sender or sender == "unknown":
raise LocalCommandError("无法确认本人身份,不能操作金币账户")
person = self.resolve_person(group, sender)
person["display_name"] = person_display_label(person["display_name"])
return person
def _player_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 player_actions
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_player_account(
self,
group: str,
person_key: str,
) -> dict[str, Any]:
stored = self.connection.execute(
"""
SELECT state_json FROM player_accounts
WHERE group_fingerprint = ? AND person_key = ?
""",
(group, person_key),
).fetchone()
if stored is None:
return normalize_player_account({})
try:
account = json.loads(str(stored["state_json"]))
except json.JSONDecodeError as error:
raise LocalCommandError("金币账户状态无效") from error
if not isinstance(account, dict):
raise LocalCommandError("金币账户状态无效")
return normalize_player_account(account)
def _save_player_account(
self,
group: str,
person_key: str,
account: dict[str, Any],
timestamp: str,
) -> None:
normalize_player_account(account)
if (
type(account["balance"]) is not int
or not 0 <= account["balance"] <= 1_000_000_000
):
raise LocalCommandError("金币结算超出允许范围")
self.connection.execute(
"""
INSERT INTO player_accounts VALUES (?, ?, ?, ?)
ON CONFLICT(group_fingerprint, person_key) DO UPDATE
SET state_json = excluded.state_json,
updated_at = excluded.updated_at
""",
(
group,
person_key,
json.dumps(account, ensure_ascii=False),
timestamp,
),
)
def _store_player_action(
self,
group: str,
event_id: str,
person_key: str,
command: str,
response: str,
timestamp: str,
) -> None:
self.connection.execute(
"INSERT INTO player_actions VALUES (?, ?, ?, ?, ?, ?)",
(group, event_id, person_key, command, response, timestamp),
)
def _open_group_lottery(self, group: str) -> sqlite3.Row | None:
return self.connection.execute(
"""
SELECT * FROM group_lottery_rounds
WHERE group_fingerprint = ? AND state = 'open'
LIMIT 1
""",
(group,),
).fetchone()
def _group_lottery_entries(
self,
group: str,
round_number: int,
) -> list[dict[str, Any]]:
rows = self.connection.execute(
"""
SELECT * FROM group_lottery_entries
WHERE group_fingerprint = ? AND round_number = ?
ORDER BY created_at, person_key
""",
(group, round_number),
).fetchall()
entries: list[dict[str, Any]] = []
for row in rows:
entry = dict(row)
try:
numbers = json.loads(str(entry["numbers_json"]))
except json.JSONDecodeError:
numbers = []
entry["numbers"] = [
int(value)
for value in numbers
if type(value) is int and 0 <= value <= 9
]
entries.append(entry)
return entries
def start_group_lottery(
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_lottery(group)
status = "already_open"
if row is None:
round_number = int(
self.connection.execute(
"""
SELECT COALESCE(MAX(round_number), 0) + 1
FROM group_lottery_rounds
WHERE group_fingerprint = ?
""",
(group,),
).fetchone()[0]
)
timestamp = utc_now()
self.connection.execute(
"""
INSERT INTO group_lottery_rounds (
group_fingerprint, round_number, state,
host_person_key, host_display_name,
drawn_numbers_json, created_at, updated_at
) VALUES (?, ?, 'open', ?, ?, '[]', ?, ?)
""",
(
group,
round_number,
person_key,
person["display_name"],
timestamp,
timestamp,
),
)
row = self._open_group_lottery(group)
status = "started"
if row is None:
raise LocalCommandError("彩票创建失败")
data = dict(row)
data["status"] = status
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_lottery_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_lottery(group)
if row is None:
data: dict[str, Any] = {
"status": "no_round",
"entries": [],
}
else:
data = dict(row)
data["status"] = "open"
data["entries"] = self._group_lottery_entries(
group, int(row["round_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 register_group_lottery(
self,
group: str,
sender: str,
event_id: str,
command: str,
numbers: tuple[int, int, int],
stake: 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_lottery(group)
if row is None:
data: dict[str, Any] = {
"status": "no_round",
"display_name": person["display_name"],
}
else:
round_number = int(row["round_number"])
existing = self.connection.execute(
"""
SELECT * FROM group_lottery_entries
WHERE group_fingerprint = ? AND round_number = ?
AND person_key = ?
""",
(group, round_number, person_key),
).fetchone()
account = self._load_player_account(group, person_key)
old_stake = int(existing["stake"]) if existing else 0
delta = stake - old_stake
if delta > int(account["balance"]):
data = {
"status": "insufficient",
"display_name": person["display_name"],
"balance": int(account["balance"]),
}
else:
account["balance"] -= delta
index = (
int(existing["player_game_index"])
if existing
else next_game_index(account, "lottery")
)
timestamp = utc_now()
self._save_player_account(
group, person_key, account, timestamp
)
self.connection.execute(
"""
INSERT INTO group_lottery_entries (
group_fingerprint, round_number, person_key,
display_name, player_game_index, numbers_json,
stake, matches, payout, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
ON CONFLICT (
group_fingerprint, round_number, person_key
) DO UPDATE SET
display_name = excluded.display_name,
numbers_json = excluded.numbers_json,
stake = excluded.stake,
matches = 0,
payout = 0,
updated_at = excluded.updated_at
""",
(
group,
round_number,
person_key,
person["display_name"],
index,
canonical_json(list(numbers)),
stake,
timestamp,
timestamp,
),
)
data = {
"status": "updated" if existing else "registered",
"display_name": person["display_name"],
"player_game_index": index,
"round_number": round_number,
"numbers": list(numbers),
"stake": stake,
"balance": int(account["balance"]),
}
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 draw_group_lottery(
self,
group: str,
sender: str,
event_id: str,
command: str,
drawn_numbers: tuple[int, int, int],
render: Callable[[dict[str, Any]], str],
) -> str:
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
if (
len(drawn_numbers) != 3
or len(set(drawn_numbers)) != 3
or any(type(value) is not int or not 0 <= value <= 9 for value in drawn_numbers)
):
raise LocalCommandError("彩票开奖号无效")
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_lottery(group)
if row is None:
data: dict[str, Any] = {"status": "no_round"}
elif str(row["host_person_key"]) != person_key:
data = {
"status": "not_host",
"host_display_name": str(row["host_display_name"]),
}
else:
round_number = int(row["round_number"])
entries = self._group_lottery_entries(
group, round_number
)
if not entries:
data = {
"status": "empty",
"round_number": round_number,
}
else:
timestamp = utc_now()
settled: list[dict[str, Any]] = []
drawn = set(drawn_numbers)
rates = {0: 0, 1: 50, 2: 120, 3: 300}
for entry in entries:
key = str(entry["person_key"])
account = self._load_player_account(group, key)
matches = len(drawn.intersection(entry["numbers"]))
stake = int(entry["stake"])
payout = stake * rates[matches] // 100
account["balance"] += payout
account[
"wins" if payout > stake else "losses"
] += 1
append_game_record(
account,
kind="lottery",
index=int(entry["player_game_index"]),
outcome=f"命中{matches}个",
stake=stake,
payout=payout,
summary=(
"选号 "
+ " ".join(map(str, entry["numbers"]))
+ ";开奖号 "
+ " ".join(map(str, drawn_numbers))
),
)
self._save_player_account(
group, key, account, timestamp
)
self.connection.execute(
"""
UPDATE group_lottery_entries
SET matches = ?, payout = ?, updated_at = ?
WHERE group_fingerprint = ?
AND round_number = ? AND person_key = ?
""",
(
matches,
payout,
timestamp,
group,
round_number,
key,
),
)
result = dict(entry)
result.update(
{
"matches": matches,
"payout": payout,
"balance": int(account["balance"]),
}
)
settled.append(result)
self.connection.execute(
"""
UPDATE group_lottery_rounds
SET state = 'drawn', drawn_numbers_json = ?,
updated_at = ?
WHERE group_fingerprint = ? AND round_number = ?
""",
(
canonical_json(list(drawn_numbers)),
timestamp,
group,
round_number,
),
)
data = {
"status": "drawn",
"round_number": round_number,
"drawn_numbers": list(drawn_numbers),
"entries": settled,
}
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_lottery(
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_lottery(group)
if row is None:
data: dict[str, Any] = {"status": "no_round"}
elif str(row["host_person_key"]) != person_key:
data = {
"status": "not_host",
"host_display_name": str(row["host_display_name"]),
}
else:
round_number = int(row["round_number"])
entries = self._group_lottery_entries(
group, round_number
)
timestamp = utc_now()
for entry in entries:
key = str(entry["person_key"])
stake = int(entry["stake"])
account = self._load_player_account(group, key)
account["balance"] += stake
append_game_record(
account,
kind="lottery",
index=int(entry["player_game_index"]),
outcome="本轮取消",
stake=stake,
payout=stake,
summary="主持人取消,下注已全额退回",
)
self._save_player_account(
group, key, account, timestamp
)
self.connection.execute(
"""
UPDATE group_lottery_rounds
SET state = 'cancelled', updated_at = ?
WHERE group_fingerprint = ? AND round_number = ?
""",
(timestamp, group, round_number),
)
data = {
"status": "cancelled",
"round_number": round_number,
"entries": entries,
}
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,
sender: str,
) -> dict[str, Any]:
person = self._player_identity(group, sender)
with self.lock:
account = self._load_player_account(
group, str(person["person_key"])
)
history = sorted(
account["game_history"],
key=lambda entry: float(entry.get("completed_at", 0)),
reverse=True,
)
return {
"display_name": person["display_name"],
"history": history,
"game_counts": dict(account["game_counts"]),
}
def apply_player_action(
self, group: str, sender: str, event_id: str, command: str,
action: Callable[[dict[str, Any]], str],
) -> str:
"""Commit identity-scoped funds, game state and replay response together."""
if not group or not event_id or not sender or sender == "unknown":
raise LocalCommandError("无法确认本人身份,不能操作金币账户")
person = self.resolve_person(group, sender)
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
previous = self.connection.execute(
"""SELECT person_key, command, response_text FROM player_actions
WHERE group_fingerprint = ? AND event_id = ?""",
(group, event_id),
).fetchone()
if previous is not None:
if previous["person_key"] != person_key or previous["command"] != command:
raise LocalCommandError("同一事件不能变更账户或游戏指令")
self.connection.execute("COMMIT")
return str(previous["response_text"])
stored = self.connection.execute(
"""SELECT state_json FROM player_accounts
WHERE group_fingerprint = ? AND person_key = ?""",
(group, person_key),
).fetchone()
account = json.loads(stored["state_json"]) if stored else {
"balance": 1000, "game": None, "wins": 0, "losses": 0, "ties": 0,
}
response = action(account)
if type(account["balance"]) is not int or not 0 <= account["balance"] <= 1_000_000_000:
raise LocalCommandError("金币结算超出允许范围")
timestamp = utc_now()
self.connection.execute(
"""INSERT INTO player_accounts VALUES (?, ?, ?, ?)
ON CONFLICT(group_fingerprint, person_key) DO UPDATE
SET state_json = excluded.state_json, updated_at = excluded.updated_at""",
(group, person_key, json.dumps(account, ensure_ascii=False), timestamp),
replay = self._player_action_replay(
group, event_id, person_key, command
)
self.connection.execute(
"INSERT INTO player_actions VALUES (?, ?, ?, ?, ?, ?)",
(group, event_id, person_key, command, response, timestamp),
if replay is not None:
self.connection.execute("COMMIT")
return replay
account = self._load_player_account(group, person_key)
response = action(account)
timestamp = utc_now()
self._save_player_account(
group, person_key, account, timestamp
)
self._store_player_action(
group,
event_id,
person_key,
command,
response,
timestamp,
)
self.connection.execute("COMMIT")
return response
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-official-extensions"
version = "0.6.2a0+1015"
version = "0.6.3a0+1015"
description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==0.6.2a0+1015",
"jwebot-sdk==0.6.3a0+1015",
]
[tool.setuptools.packages.find]
@@ -62,4 +62,4 @@ __all__ = [
"render_feature_list",
]
__version__ = "0.6.2a0+1015"
__version__ = "0.6.3a0+1015"
@@ -5,9 +5,9 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games'
AUTHOR = 'jwebot'
DESCRIPTION = '本人虚拟金币账户、抽奖、彩票和单人21点'
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始 [金额](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点'
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
def _handler(context: SingleGroupContext, command: str):
@@ -44,11 +44,15 @@ LOCAL_COMMAND_ALIASES = {
"wallet": "wallet",
"抽奖": "prize_draw",
"draw": "prize_draw",
"彩票": "lottery",
"买彩票": "lottery",
"lottery": "lottery",
"21点": "blackjack",
"二十一点": "blackjack",
"blackjack": "blackjack",
"游戏记录": "game_history",
"个人记录": "game_history",
"gamehistory": "game_history",
"游戏帮助": "game_rules",
"games": "game_rules",
"人物列表": "person_directory",
@@ -120,14 +124,22 @@ LOCAL_EXTENSIONS = (
LocalExtensionDescriptor(
extension_id="single-player-games",
author=JWEBOT_AUTHOR,
description="本人虚拟金币账户、抽奖、彩票和单人21点",
description="虚拟金币账户、抽奖、群内多人彩票和单人21点",
help_entries=(
"/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery",
"/21点 开始 [金额](金额=本局下注,默认50,空格可省略)",
"/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory",
"/彩票 开始|报名|当前状态|开奖|取消;/lottery",
"/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)",
"/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end",
"/游戏帮助;/games",
),
command_kinds=("wallet", "prize_draw", "lottery", "blackjack", "game_rules"),
command_kinds=(
"wallet",
"prize_draw",
"lottery",
"blackjack",
"game_history",
"game_rules",
),
),
LocalExtensionDescriptor(
extension_id="person-directory",
@@ -119,7 +119,14 @@ class LocalCommandEngine:
return self.reply_cleaner(self.context_summary(row, arguments))
if kind in {"person_directory", "person_info"}:
return self.reply_cleaner(self.person_index(row, kind, arguments))
if kind in {"wallet", "prize_draw", "lottery", "blackjack", "game_rules"}:
if kind in {
"wallet",
"prize_draw",
"lottery",
"blackjack",
"game_history",
"game_rules",
}:
return self.reply_cleaner(self.players.handle(row, kind, arguments))
if kind == "dice":
return self.reply_cleaner(self.dice(row, arguments))
@@ -9,8 +9,21 @@ from typing import Any
INITIAL_COINS = 1000
MAX_BET = 100
MIN_DRAW_BET = 2
MAX_DRAW_BET = 100
MIN_LOTTERY_BET = 1
MAX_LOTTERY_BET = 500
DEFAULT_LOTTERY_BET = 10
MIN_BLACKJACK_BET = 1
MAX_BLACKJACK_BET = 500
GAME_TIMEOUT_SECONDS = 600
GAME_HISTORY_LIMIT = 50
GAME_HISTORY_PAGE_SIZE = 5
GAME_LABELS = {
"prize_draw": "抽奖",
"lottery": "彩票",
"blackjack": "21点",
}
BLACKJACK_ACTIONS = {
"要牌": "hit",
"hit": "hit",
@@ -30,6 +43,68 @@ BLACKJACK_COMBINED_ACTION = re.compile(
)
def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
account.setdefault("balance", INITIAL_COINS)
account.setdefault("game", None)
account.setdefault("wins", 0)
account.setdefault("losses", 0)
account.setdefault("ties", 0)
counts = account.get("game_counts")
if not isinstance(counts, dict):
counts = {}
account["game_counts"] = counts
for kind in GAME_LABELS:
value = counts.get(kind, 0)
counts[kind] = value if type(value) is int and value >= 0 else 0
history = account.get("game_history")
if not isinstance(history, list):
history = []
account["game_history"] = [
entry for entry in history if isinstance(entry, dict)
][-GAME_HISTORY_LIMIT:]
return account
def next_game_index(account: dict[str, Any], kind: str) -> int:
normalize_player_account(account)
counts = account["game_counts"]
counts[kind] = int(counts.get(kind, 0)) + 1
return int(counts[kind])
def append_game_record(
account: dict[str, Any],
*,
kind: str,
index: int,
outcome: str,
stake: int,
payout: int,
summary: str,
) -> None:
normalize_player_account(account)
history = [
entry
for entry in account["game_history"]
if not (
entry.get("kind") == kind
and int(entry.get("index", 0)) == index
)
]
history.append(
{
"kind": kind,
"index": index,
"outcome": outcome[:80],
"stake": stake,
"payout": payout,
"summary": summary[:240],
"completed_at": time.time(),
}
)
account["game_history"] = history[-GAME_HISTORY_LIMIT:]
def card_label(card: int) -> str:
return ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")[card % 13]
@@ -52,13 +127,15 @@ class PlayerGames:
@staticmethod
def rules() -> str:
return (
"单人游戏:每人首次使用获 1000 虚拟金币;每次下注 2~100。\n"
"/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [000~999] [金额] /lottery;"
"21点先用 /21点 开始 [金额] 进场,金额是本局下注,默认50;空格可省略。\n"
"每人首次使用获 1000 虚拟金币;/金币 查看余额;/游戏记录 查看本人记录。\n"
"抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n"
"多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];"
"/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;"
"命中1/2/3个返下注的50%/120%/300%。\n"
"21点先用 /21点 开始 [1~500] 进场,金额是本局下注,默认50;空格可省略。\n"
"看到牌后再单独发送 /21点 要牌|停牌|状态|结束"
"(/blackjack hit|stand|status|end);"
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
"抽奖:5%返5倍、20%返2倍,其余无返还;彩票命中返500倍;"
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
"以上均含本金;虚拟金币不能充值、转账或兑换。"
)
@@ -69,18 +146,16 @@ class PlayerGames:
return self.rules()
if arguments.casefold() in {"帮助", "规则", "help", "rules"}:
return self.rules()
if kind == "game_history":
return self.game_history(row, arguments)
if kind == "lottery":
return self.group_lottery(row, arguments)
if kind == "wallet" and arguments:
return "用法:/金币(/wallet),查看本人的虚拟金币;/游戏帮助 查看规则"
if kind == "prize_draw":
if arguments and not re.fullmatch(r"\d{1,3}", arguments):
return "用法:/抽奖 [2~100金币](/draw),默认10金币"
bet = int(arguments or 10)
elif kind == "lottery":
match = re.fullmatch(r"(?:(\d{3})(?:\s+(\d{1,3}))?)?", arguments)
if match is None:
return "用法:/买彩票 [三位号码000~999] [2~100金币](/lottery),默认随机号码、10金币"
number = int(match[1]) if match[1] is not None else None
bet = int(match[2] or 10)
elif kind == "blackjack":
compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态"
start = BLACKJACK_START.fullmatch(compacted)
@@ -100,27 +175,48 @@ class PlayerGames:
"/21点 要牌 或 /21点 停牌"
)
return (
"用法:先 /21点 开始 [2~100](金额是本局下注,默认50);"
"用法:先 /21点 开始 [1~500](金额是本局下注,默认50);"
"开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略"
)
else:
bet = 0
if kind in {"prize_draw", "lottery"} or kind == "blackjack" and action == "start":
if not 2 <= bet <= MAX_BET:
return "下注必须为 2~100 的整数金币"
if kind == "prize_draw" and not MIN_DRAW_BET <= bet <= MAX_DRAW_BET:
return "抽奖下注必须为 2~100 的整数金币"
if (
kind == "blackjack"
and action == "start"
and not MIN_BLACKJACK_BET <= bet <= MAX_BLACKJACK_BET
):
return "21点下注必须为 1~500 的整数金币"
person = self.ledger.resolve_person(
str(row["group_fingerprint"]),
str(row["sender"]),
)
player_label = self.display_label(person["display_name"])
def blackjack_reply(text: str) -> str:
return f"{player_label}:{text}"
def player_reply(text: str, index: int | None = None) -> str:
suffix = f" #{index}" if index is not None else ""
return f"{player_label}{suffix}:{text}"
def execute(account: dict[str, Any]) -> str:
normalize_player_account(account)
expired = ""
game = account.get("game")
if game and (
type(game.get("index")) is not int
or int(game["index"]) < 1
):
game["index"] = next_game_index(account, "blackjack")
if game and time.time() - float(game["updated_at"]) >= GAME_TIMEOUT_SECONDS:
append_game_record(
account,
kind="blackjack",
index=int(game["index"]),
outcome="超时认输",
stake=int(game["bet"]),
payout=0,
summary="10分钟无有效操作,按认输结束",
)
account["game"] = None
account["losses"] += 1
expired = "上一局已超时,按认输结束;"
@@ -130,44 +226,285 @@ class PlayerGames:
f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。"
"首次赠送1000仅发一次;/游戏帮助 查看规则")
if kind == "blackjack" and action in {"hit", "stand", "status", "end"}:
return blackjack_reply(
expired + self.play_blackjack(account, action)
active = account.get("game")
index = int(active["index"]) if active else None
return player_reply(
expired + self.play_blackjack(account, action),
index,
)
if account.get("game"):
message = expired + "你已有一局21点,请先 /21点 停牌 或 /21点 结束"
return blackjack_reply(message) if kind == "blackjack" else message
return (
player_reply(message, int(account["game"]["index"]))
if kind == "blackjack"
else message
)
if account["balance"] < bet:
message = expired + f"金币不足:余额 {account['balance']},本次需要 {bet}"
return blackjack_reply(message) if kind == "blackjack" else message
return player_reply(message) if kind == "blackjack" else message
account["balance"] -= bet
if kind == "blackjack":
index = next_game_index(account, "blackjack")
deck = list(range(52))
self.rng.shuffle(deck)
game = {"bet": bet, "deck": deck[4:], "player": deck[:2],
"dealer": deck[2:4], "updated_at": time.time()}
"dealer": deck[2:4], "updated_at": time.time(),
"index": index}
account["game"] = game
if hand_value(game["player"]) == 21 or hand_value(game["dealer"]) == 21:
return blackjack_reply(
expired + self.finish_blackjack(account, natural=True)
return player_reply(
expired + self.finish_blackjack(account, natural=True),
index,
)
return blackjack_reply(expired + self.show_hand(game))
if kind == "prize_draw":
roll = self.rng.randrange(100)
payout = bet * (5 if roll < 5 else 2 if roll < 25 else 0)
result = "大奖" if roll < 5 else "小奖" if roll < 25 else "未中奖"
else:
selected = self.rng.randrange(1000) if number is None else number
winning = self.rng.randrange(1000)
payout = bet * 500 if selected == winning else 0
result = f"号码 {selected:03d};开奖号 {winning:03d};" + ("中奖" if payout else "未中奖")
return player_reply(expired + self.show_hand(game), index)
index = next_game_index(account, "prize_draw")
roll = self.rng.randrange(100)
payout = bet * (5 if roll < 5 else 2 if roll < 25 else 0)
result = "大奖" if roll < 5 else "小奖" if roll < 25 else "未中奖"
account["balance"] += payout
account["wins" if payout > bet else "losses"] += 1
return expired + f"{result};下注 {bet},返还 {payout},净变化 {payout - bet:+d};金币 {account['balance']}"
append_game_record(
account,
kind="prize_draw",
index=index,
outcome=result,
stake=bet,
payout=payout,
summary=f"抽奖结果 {result}",
)
return player_reply(
expired
+ f"{result};下注 {bet},返还 {payout},"
f"净变化 {payout - bet:+d};金币 {account['balance']}",
index,
)
return self.ledger.apply_player_action(
str(row["group_fingerprint"]), str(row["sender"]),
str(row["event_id"]), kind + ":" + arguments, execute)
@staticmethod
def parse_lottery_entry(
arguments: str,
) -> tuple[tuple[int, int, int], int] | None:
value = arguments.strip().casefold()
for prefix in ("报名", "join"):
if value.startswith(prefix):
value = value[len(prefix):].strip()
break
if not value:
return None
marked = re.fullmatch(
r"([0-9])\s*([0-9])\s*([0-9])"
r"(?:金额|下注|bet)(\d{1,3})",
re.sub(r"[,,、]", "", value),
)
stake: int
if marked is not None:
digits = tuple(int(marked[index]) for index in range(1, 4))
stake = int(marked[4])
else:
tokens = re.sub(r"[,,、]", " ", value).split()
digits: tuple[int, int, int]
if len(tokens) == 1 and tokens[0].isdigit():
compact = tokens[0]
if not 3 <= len(compact) <= 6:
return None
digits = tuple(int(item) for item in compact[:3])
stake = int(compact[3:] or DEFAULT_LOTTERY_BET)
elif (
len(tokens) == 2
and re.fullmatch(r"\d{3}", tokens[0])
and re.fullmatch(r"\d{1,3}", tokens[1])
):
digits = tuple(int(item) for item in tokens[0])
stake = int(tokens[1])
elif (
len(tokens) in {3, 4}
and all(re.fullmatch(r"\d", item) for item in tokens[:3])
and (
len(tokens) == 3
or re.fullmatch(r"\d{1,3}", tokens[3])
)
):
digits = tuple(int(item) for item in tokens[:3])
stake = (
int(tokens[3])
if len(tokens) == 4
else DEFAULT_LOTTERY_BET
)
else:
return None
if len(set(digits)) != 3:
raise ValueError("彩票的三个数字必须互不重复")
if not MIN_LOTTERY_BET <= stake <= MAX_LOTTERY_BET:
raise ValueError("彩票下注必须为 1~500 的整数金币")
return tuple(sorted(digits)), stake
def group_lottery(self, row: dict[str, Any], arguments: str) -> str:
group = str(row["group_fingerprint"])
sender = str(row["sender"])
event_id = str(row["event_id"])
command = "lottery:" + arguments
compacted = re.sub(r"\s+", "", arguments.casefold())
def status_text(data: dict[str, Any]) -> str:
if data["status"] == "no_round":
return "当前没有进行中的彩票;发送 /彩票 开始 创建新一把"
entries = data["entries"]
lines = [
f"彩票第 {data['round_number']} 把正在报名;"
f"主持人 {data['host_display_name']};已报名 {len(entries)} 人"
]
for entry in entries:
lines.append(
f"{entry['display_name']} #{entry['player_game_index']}:"
f"{' '.join(map(str, entry['numbers']))},下注 {entry['stake']}"
)
if not entries:
lines.append("暂无报名;/彩票 报名 3 4 7 [金额]")
return "\n".join(lines)
if compacted in {"开始", "start"}:
return self.ledger.start_group_lottery(
group,
sender,
event_id,
command,
lambda data: (
f"彩票第 {data['round_number']} 把"
+ ("已经在报名中" if data["status"] == "already_open" else "已开始")
+ f";主持人 {data['host_display_name']}。\n"
"报名:/彩票 3 4 7 [金额](金额1~500,默认10,空格可省略);"
"查询:/彩票 当前状态;主持人开奖:/彩票 开奖"
),
)
if compacted in {"当前状态", "状态", "status"}:
return self.ledger.group_lottery_status(
group,
sender,
event_id,
command,
status_text,
)
if compacted in {"开奖", "draw"}:
drawn = tuple(sorted(self.rng.sample(range(10), 3)))
def draw_text(data: dict[str, Any]) -> str:
if data["status"] == "no_round":
return "当前没有可以开奖的彩票"
if data["status"] == "not_host":
return (
f"只有本轮主持人 {data['host_display_name']} 可以开奖"
)
if data["status"] == "empty":
return "本轮还没有人报名,不能开奖"
lines = [
f"彩票第 {data['round_number']} 把开奖号:"
f"{' '.join(map(str, data['drawn_numbers']))}"
]
for entry in data["entries"]:
lines.append(
f"{entry['display_name']} #{entry['player_game_index']}:"
f"命中 {entry['matches']} 个;下注 {entry['stake']},"
f"返还 {entry['payout']},净变化 "
f"{entry['payout'] - entry['stake']:+d};"
f"金币 {entry['balance']}"
)
return "\n".join(lines)
return self.ledger.draw_group_lottery(
group,
sender,
event_id,
command,
drawn,
draw_text,
)
if compacted in {"取消", "cancel"}:
return self.ledger.cancel_group_lottery(
group,
sender,
event_id,
command,
lambda data: (
"当前没有进行中的彩票"
if data["status"] == "no_round"
else (
f"只有本轮主持人 {data['host_display_name']} 可以取消"
if data["status"] == "not_host"
else (
f"彩票第 {data['round_number']} 把已取消;"
f"{len(data['entries'])} 人的下注已全额退回"
)
)
),
)
try:
parsed = self.parse_lottery_entry(arguments)
except ValueError as error:
return str(error)
if parsed is None:
return (
"用法:先 /彩票 开始;再 /彩票 报名 3 4 7 [金额]"
"(也可 /彩票347,金额1~500、默认10);"
"/彩票 当前状态;主持人 /彩票 开奖"
)
numbers, stake = parsed
def register_text(data: dict[str, Any]) -> str:
if data["status"] == "no_round":
return "当前没有进行中的彩票;先发送 /彩票 开始"
if data["status"] == "insufficient":
return (
f"{data['display_name']}:金币不足,余额 {data['balance']},"
f"报名需要 {stake}"
)
action = "报名已更新" if data["status"] == "updated" else "报名成功"
return (
f"{data['display_name']} #{data['player_game_index']}:"
f"彩票第 {data['round_number']} 把{action};号码 "
f"{' '.join(map(str, data['numbers']))};下注 {data['stake']};"
f"金币 {data['balance']}"
)
return self.ledger.register_group_lottery(
group,
sender,
event_id,
command,
numbers,
stake,
register_text,
)
def game_history(self, row: dict[str, Any], arguments: str) -> str:
if arguments and not re.fullmatch(r"\d{1,3}", arguments):
return "用法:/游戏记录 [页码](/gamehistory),每页5条"
page = int(arguments or 1)
if page < 1:
return "游戏记录页码必须从 1 开始"
data = self.ledger.player_game_history(
str(row["group_fingerprint"]),
str(row["sender"]),
)
records = data["history"]
pages = max(1, (len(records) + GAME_HISTORY_PAGE_SIZE - 1) // GAME_HISTORY_PAGE_SIZE)
if page > pages:
return f"{data['display_name']} 只有 {pages} 页游戏记录"
start = (page - 1) * GAME_HISTORY_PAGE_SIZE
lines = [f"{data['display_name']} 的游戏记录 {page}/{pages}:"]
for record in records[start:start + GAME_HISTORY_PAGE_SIZE]:
label = GAME_LABELS.get(str(record["kind"]), str(record["kind"]))
lines.append(
f"{data['display_name']} #{record['index']}:{label} {record['outcome']};"
f"下注 {record['stake']},返还 {record['payout']},"
f"净变化 {record['payout'] - record['stake']:+d}"
)
if not records:
lines.append("暂无已结算记录")
return "\n".join(lines)
@staticmethod
def show_hand(game: dict[str, Any]) -> str:
player = " ".join(card_label(card) for card in game["player"])
@@ -182,6 +519,17 @@ class PlayerGames:
if action == "status":
return self.show_hand(game)
if action == "end":
index = int(game["index"])
bet = int(game["bet"])
append_game_record(
account,
kind="blackjack",
index=index,
outcome="主动认输",
stake=bet,
payout=0,
summary="主动结束牌局,不退下注",
)
account["game"] = None
account["losses"] += 1
return f"本人的21点已认输结束,不退下注;金币 {account['balance']}"
@@ -213,6 +561,17 @@ class PlayerGames:
outcome, payout, stat = "你输了", 0, "losses"
account["balance"] += payout
account[stat] += 1
append_game_record(
account,
kind="blackjack",
index=int(game["index"]),
outcome=outcome,
stake=int(bet),
payout=int(payout),
summary=(
f"玩家{player}点,庄家{dealer}点"
),
)
account["game"] = None
return (f"21点结算:{outcome};你的牌 {' '.join(map(card_label, game['player']))}({player}点),"
f"庄家 {' '.join(map(card_label, game['dealer']))}({dealer}点);"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-sdk"
version = "0.6.2a0+1015"
version = "0.6.3a0+1015"
description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9"
dependencies = []
@@ -38,4 +38,4 @@ __all__ = [
"SingleGroupContext",
]
__version__ = "0.6.2a0+1015"
__version__ = "0.6.3a0+1015"