Release beta 1.0.5 primogem economy
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user