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
+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