Release beta 1.0.5 primogem economy

This commit is contained in:
JWeBot Contributors
2026-10-16 16:46:44 +08:00
parent 127fea8cd0
commit b1d8e4e8e7
50 changed files with 1615 additions and 88 deletions
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-kernel"
version = "1.0.4b1+1016"
version = "1.0.5b1+1016"
description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-config==1.0.4b1+1016",
"jwebot-sdk==1.0.4b1+1016",
"jwebot-official-extensions==1.0.4b1+1016",
"jwebot-config==1.0.5b1+1016",
"jwebot-sdk==1.0.5b1+1016",
"jwebot-official-extensions==1.0.5b1+1016",
"tomli>=2; python_version < '3.11'",
]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature",
]
__version__ = "1.0.4b1+1016"
__version__ = "1.0.5b1+1016"
@@ -74,9 +74,12 @@ from jwebot_official_extensions.player_games import (
append_coin_event,
append_game_coin_event,
append_game_record,
blackjack_achievement_lines,
hand_value,
lottery_achievement_lines,
next_game_index,
normalize_player_account,
spend_primogems,
)
from jwebot_sdk import Event, PlanKind
@@ -2847,13 +2850,54 @@ class Ledger:
timestamp,
),
)
if cursor.rowcount == 0 and kind == "taffy_speech":
cursor = self.connection.execute(
"""
UPDATE clock_broadcasts
SET state = 'pending', updated_at = ?
WHERE group_fingerprint = ? AND slot_key = ?
AND kind = 'taffy_speech' AND state = 'scheduled'
""",
(
timestamp,
group_fingerprint,
slot_key,
),
)
return cursor.rowcount == 1
def due_scheduled_taffy_speech(
self,
group_fingerprint: str,
now: datetime,
) -> dict[str, Any] | None:
group = bounded_text(group_fingerprint, limit=256)
if not group:
return None
local_now = now.astimezone(BEIJING_TIMEZONE)
due_prefix = local_now.strftime("taffy-speech:%Y-%m-%dT%H:%M")
with self.lock:
row = self.connection.execute(
"""
SELECT group_fingerprint, slot_key, local_date, kind, message
FROM clock_broadcasts
WHERE group_fingerprint = ?
AND kind = 'taffy_speech'
AND state = 'scheduled'
AND slot_key <= ?
ORDER BY slot_key
LIMIT 1
""",
(group, due_prefix + ":~"),
).fetchone()
return dict(row) if row else None
def complete_clock_broadcast(
self,
group_fingerprint: str,
slot_key: str,
) -> bool:
consume_boost_key = ""
with self.lock, self.connection:
row = self.connection.execute(
"""
@@ -2894,6 +2938,9 @@ class Ledger:
utc_now(),
),
)
consume_boost_key = person_key
if consume_boost_key:
self.consume_scan_boost(group_fingerprint, consume_boost_key)
return True
def fail_clock_broadcast(
@@ -3609,6 +3656,10 @@ class Ledger:
stake=stake,
payout=payout,
)
achievements = lottery_achievement_lines(
account,
matches=matches,
)
self._save_player_account(
group, key, account, timestamp
)
@@ -3634,6 +3685,7 @@ class Ledger:
"matches": matches,
"payout": payout,
"balance": int(account["balance"]),
"achievements": achievements,
}
)
settled.append(result)
@@ -3895,6 +3947,18 @@ class Ledger:
stake=bet,
payout=payout,
)
achievements = blackjack_achievement_lines(
account,
outcome=outcome,
natural_player_21=(
len(player["hand"]) == 2 and player_value == 21
),
extreme_21=(
len(player["hand"]) > 2
and hand_value(player["hand"][:-1]) == 20
and player_value == 21
),
)
self._save_player_account(group, key, account, timestamp)
self.connection.execute(
"""
@@ -3922,6 +3986,7 @@ class Ledger:
"balance": int(account["balance"]),
"player_value": player_value,
"dealer_value": dealer_value,
"achievements": achievements,
}
)
settled.append(result)
@@ -4927,6 +4992,216 @@ class Ledger:
)
return records[: max(1, min(int(limit), 20))]
def player_primogem_ranking(
self,
group: str,
*,
limit: int = 10,
) -> list[dict[str, Any]]:
group = bounded_text(group, limit=256)
self.seed_person_aliases(group)
with self.lock:
account_rows = [
dict(row)
for row in self.connection.execute(
"""
SELECT person_key, state_json, updated_at
FROM player_accounts
WHERE group_fingerprint = ?
""",
(group,),
).fetchall()
]
alias_rows = [
dict(row)
for row in self.connection.execute(
"""
SELECT DISTINCT canonical_name
FROM person_aliases
WHERE group_fingerprint = ?
""",
(group,),
).fetchall()
]
profile_rows = [
dict(row)
for row in self.connection.execute(
"""
SELECT person_key, display_name
FROM person_profiles
WHERE group_fingerprint = ?
""",
(group,),
).fetchall()
]
observation_rows = [
dict(row)
for row in self.connection.execute(
"""
SELECT person_key, display_name
FROM person_observations
WHERE group_fingerprint = ?
ORDER BY observed_at DESC, created_at DESC
""",
(group,),
).fetchall()
]
labels: dict[str, str] = {}
for row in alias_rows:
canonical = bounded_text(row["canonical_name"], limit=120)
if canonical:
labels[person_scope_key(group, canonical)] = person_display_label(
canonical
)
for row in profile_rows:
person_key = str(row["person_key"])
labels.setdefault(
person_key,
person_display_label(row["display_name"]),
)
for row in observation_rows:
person_key = str(row["person_key"])
labels.setdefault(
person_key,
person_display_label(row["display_name"]),
)
records: list[dict[str, Any]] = []
for row in account_rows:
person_key = str(row["person_key"])
try:
account = json.loads(str(row["state_json"]))
except json.JSONDecodeError as error:
raise LocalCommandError("原石账户状态无效") from error
if not isinstance(account, dict):
raise LocalCommandError("原石账户状态无效")
normalize_player_account(account)
events = [
event
for event in account["primogem_events"]
if int(event.get("delta", 0)) > 0
]
source_counts: dict[str, int] = {}
for event in events:
reason = bounded_text(event.get("reason"), limit=40) or "原石来源"
source_counts[reason] = source_counts.get(reason, 0) + int(
event["delta"]
)
sources = "、".join(
f"{reason}+{amount}"
for reason, amount in sorted(
source_counts.items(),
key=lambda item: (-item[1], item[0]),
)[:4]
)
if not sources:
sources = "暂无正向来源"
records.append(
{
"display_name": labels.get(
person_key,
f"玩家 {person_key[:8]}",
),
"primogems": int(account["primogems"]),
"sources": sources,
"updated_at": str(row["updated_at"]),
}
)
records = [
record
for record in records
if int(record["primogems"]) > 0 or record["sources"] != "暂无正向来源"
]
records.sort(
key=lambda item: (
-int(item["primogems"]),
str(item["display_name"]).casefold(),
)
)
return records[: max(1, min(int(limit), 20))]
def active_scan_boost_targets(
self,
group: str,
) -> dict[str, dict[str, Any]]:
group = bounded_text(group, limit=256)
boosts: dict[str, dict[str, Any]] = {}
with self.lock:
rows = self.connection.execute(
"""
SELECT state_json
FROM player_accounts
WHERE group_fingerprint = ?
""",
(group,),
).fetchall()
for row in rows:
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)
for boost in account["scan_boosts"]:
target_key = str(boost["target_person_key"])
current = boosts.setdefault(
target_key,
{
"person_key": target_key,
"display_name": str(boost["target_display_name"]),
"extra_weight": 0,
},
)
current["extra_weight"] += int(boost["weight"])
return boosts
def consume_scan_boost(
self,
group: str,
target_person_key: str,
) -> None:
group = bounded_text(group, limit=256)
target_key = bounded_text(target_person_key, limit=128)
if not group or not target_key:
return
timestamp = utc_now()
with self.lock, self.connection:
rows = self.connection.execute(
"""
SELECT person_key, state_json
FROM player_accounts
WHERE group_fingerprint = ?
""",
(group,),
).fetchall()
for row in rows:
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)
changed = False
for boost in account["scan_boosts"]:
if str(boost["target_person_key"]) == target_key:
boost["remaining"] = int(boost["remaining"]) - 1
changed = True
break
if changed:
account["scan_boosts"] = [
boost
for boost in account["scan_boosts"]
if int(boost["remaining"]) > 0
]
self._save_player_account(
group,
str(row["person_key"]),
account,
timestamp,
)
return
def player_taffy_coin_stats(
self,
group: str,
@@ -5156,6 +5431,97 @@ class Ledger:
self.connection.execute("ROLLBACK")
raise
def player_taffy_say(
self,
group: str,
sender: str,
event_id: str,
command: str,
content: str,
scheduled_at: float | None,
render: Callable[[dict[str, Any]], str],
) -> str:
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
message = bounded_text(f"Taffy:{content}", limit=MAX_REPLY_CHARS)
if not message or message == "Taffy:":
raise LocalCommandError("Taffy发话内容不能为空")
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
account = self._load_player_account(group, person_key)
if int(account["taffy_say_credits"]) <= 0:
if int(account["primogems"]) <= 0:
data = {"status": "insufficient"}
else:
spend_primogems(account, 1, "兑换Taffy发话2次")
account["taffy_say_credits"] += 2
if int(account["taffy_say_credits"]) > 0:
account["taffy_say_credits"] -= 1
timestamp = utc_now()
if scheduled_at is None:
data = {
"status": "sent",
"content": content,
"primogems": int(account["primogems"]),
"taffy_say_credits": int(
account["taffy_say_credits"]
),
}
else:
scheduled = datetime.fromtimestamp(
scheduled_at,
BEIJING_TIMEZONE,
)
scheduled_key = scheduled.strftime("%Y-%m-%dT%H:%M")
slot_key = f"taffy-speech:{scheduled_key}:{event_id}"
self.connection.execute(
"""
INSERT OR IGNORE INTO clock_broadcasts (
group_fingerprint, slot_key, local_date, kind,
message, scan_person_key, scan_label, state,
created_at, updated_at
) VALUES (?, ?, ?, 'taffy_speech', ?, '', '',
'scheduled', ?, ?)
""",
(
group,
slot_key,
scheduled.date().isoformat(),
message,
timestamp,
timestamp,
),
)
data = {
"status": "scheduled",
"content": content,
"scheduled_text": scheduled.strftime(
"%m月%d日 %H:%M"
),
"primogems": int(account["primogems"]),
"taffy_say_credits": int(
account["taffy_say_credits"]
),
}
response = render(data)
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
except BaseException:
self.connection.execute("ROLLBACK")
raise
def apply_player_action(
self, group: str, sender: str, event_id: str, command: str,
action: Callable[[dict[str, Any]], str],
@@ -5570,7 +5936,18 @@ class HourlyBroadcastEngine:
if local_now.minute >= CLOCK_GRACE_MINUTES:
return None
group = group_fingerprint or self.active_group_fingerprint()
if not group or not self.ledger.is_clock_broadcast_group(group):
if not group:
return None
scheduled = self.ledger.due_scheduled_taffy_speech(group, local_now)
if scheduled is not None:
return ClockBroadcast(
group_fingerprint=str(scheduled["group_fingerprint"]),
slot_key=str(scheduled["slot_key"]),
local_date=str(scheduled["local_date"]),
kind=str(scheduled["kind"]),
message=str(scheduled["message"]),
)
if not self.ledger.is_clock_broadcast_group(group):
return None
if local_now.hour == 0:
previous_day = (local_now - timedelta(days=1)).date()
@@ -5597,14 +5974,33 @@ class HourlyBroadcastEngine:
)
local_date = local_now.date().isoformat()
if not CLOCK_SCAN_TARGETS:
weighted_targets: list[dict[str, str]] = [
{
"person_key": person_scope_key(group, canonical_name),
"label": scan_label,
}
for canonical_name, scan_label in CLOCK_SCAN_TARGETS
]
boosts = self.ledger.active_scan_boost_targets(group)
for target_key, boost in boosts.items():
weighted_targets.extend(
[
{
"person_key": target_key,
"label": str(boost["display_name"]),
}
]
* max(0, int(boost.get("extra_weight", 0)))
)
if not weighted_targets:
return None
target_index = deterministic_int(
f"{group}|{local_date}|{local_now.hour:02d}|clock-scan",
0,
len(CLOCK_SCAN_TARGETS) - 1,
len(weighted_targets) - 1,
)
canonical_name, scan_label = CLOCK_SCAN_TARGETS[target_index]
target = weighted_targets[target_index]
scan_label = target["label"]
return ClockBroadcast(
group_fingerprint=group,
slot_key=f"hourly:{local_date}T{local_now.hour:02d}",
@@ -5614,7 +6010,7 @@ class HourlyBroadcastEngine:
f"现在是北京时间 {local_now.hour}:00,"
f"{scan_label} 扫码了。"
),
scan_person_key=person_scope_key(group, canonical_name),
scan_person_key=target["person_key"],
scan_label=scan_label,
)