Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions 红包/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 红包

ToolDelta 类式插件。玩家可以使用经济计分板发送拼手气口令红包。

## 使用

```text
.fhb <总金额> <份数> <口令>
.发红包 <总金额> <份数> <口令>
```

缺少参数时,插件会在聊天栏逐项询问。输入 `取消` 或 `q` 可以退出。

- 红包会向全服展示发送者、总金额和领取口令。
- 领取成功消息始终全服播报。
- 红包被抢完后会全服播报领取金额最高的玩家。
- 每名玩家在同一个红包中只能领取一次,发送者也可以领取。
- 红包 10 分钟未领完时,剩余金额自动退回;重启 ToolDelta 不会重置计时。
- `�`、控制符、不可见格式符等无效字符不能用于口令;尝试使用时会收到禁止提示。

## 配置

首次加载后会生成 `插件配置文件/红包.json`:

```json
{
"经济计分板": "money",
"货币名称": "星尘",
"红包有效期秒数": 600
}
```

需要安装 `聊天栏菜单` 0.4.1 或更高版本。
182 changes: 182 additions & 0 deletions 红包/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""ToolDelta 拼手气红包插件。"""
from __future__ import annotations

import threading
from typing import Any

from tooldelta import Chat, FrameExit, Player, Plugin, plugin_entry, utils

from .runtime_reload import reload_plugin_modules

reload_plugin_modules(__name__)

from .broadcasting import RedPacketBroadcastMixin # noqa: E402
from .configuration import load_configuration # noqa: E402
from .interaction import CommandCollector # noqa: E402
from .messages import is_red_packet_command # noqa: E402
from .models import RedPacketState, player_identity # noqa: E402
from .service import RedPacketService # noqa: E402
from .storage import RedPacketStore # noqa: E402
from .text_validation import ( # noqa: E402
INVALID_CHARACTER_MESSAGE,
has_invalid_characters,
)


class LuckyRedPacket(RedPacketBroadcastMixin, Plugin):
"""让玩家发送并领取拼手气口令红包。"""

name = "红包"
author = "Q3CC"
version = (0, 2, 3)
description = "支持全服口令领取的拼手气红包"

def __init__(self, frame: Any) -> None:
"""初始化配置、持久化状态和事件监听器。"""
super().__init__(frame)
(
self.config,
self.scoreboard_name,
self.currency_name,
self.expiry_seconds,
) = load_configuration(
self.name,
self.version,
)
self._prompting_players: set[str] = set()
self._prompting_lock = threading.Lock()
self._stop_event = threading.Event()
self._storage_ready = True
store = RedPacketStore(self.data_path / "红包状态.json")
try:
state = store.load()
except ValueError as err:
self._storage_ready = False
state = RedPacketState()
self.print_err(f"{err};为防止资产异常,红包功能已停用")
self.service = RedPacketService(self, store, state)
self.ListenPreload(self.on_preload)
self.ListenActive(self.on_active)
self.ListenChat(self.on_chat)
self.ListenPlayerJoin(self.on_player_join)
self.ListenFrameExit(self.on_frame_exit)

def on_preload(self) -> None:
"""注册聊天栏红包命令。"""
chatbar = self.GetPluginAPI("聊天栏菜单", (0, 4, 1))
chatbar.add_new_trigger(
["fhb", "发红包"],
[
("总金额", str, ""),
("份数", str, ""),
("口令", str, ""),
],
"发送拼手气红包",
self.on_red_packet_command,
)

def on_active(self) -> None:
"""恢复到期任务并启动退款检查线程。"""
if not self._storage_ready:
return
self.service.process_expired()
utils.createThread(self._expiry_loop, usage="红包到期退款")
self.print_suc(
f"红包插件已启用,货币为 {self.currency_name},"
f"有效期 {self.expiry_seconds} 秒"
)

def on_frame_exit(self, _: FrameExit) -> None:
"""通知后台退款线程停止。"""
self._stop_event.set()

def on_red_packet_command(
self,
player: Player,
args: tuple[Any, ...],
) -> bool:
"""接收红包命令并立即释放聊天栏菜单回调。"""
if not self._storage_ready:
player.show("§c红包数据异常,功能暂时不可用,请联系管理员§r")
return True
identity = player_identity(player)
with self._prompting_lock:
if identity in self._prompting_players:
player.show("§e你正在创建红包,请先完成当前输入或发送 q 取消§r")
return True
self._prompting_players.add(identity)
utils.createThread(
lambda: self._run_red_packet_command(player, args, identity),
usage=f"玩家 {player.name} 创建红包",
)
return True

def _run_red_packet_command(
self,
player: Player,
args: tuple[Any, ...],
identity: str,
) -> None:
"""在独立线程中完成缺参询问和红包创建。"""
collector = CommandCollector(
player,
args,
lambda _: None,
)
try:
request = collector.collect()
if request is not None:
if not request.has_valid_phrase():
player.show(f"§c{INVALID_CHARACTER_MESSAGE}§r")
return
self.service.create(player, request)
except Exception as err:
self.print_err(f"玩家 {player.name} 创建红包失败: {err}")
player.show("§c创建红包时发生错误,请稍后重试§r")
finally:
self._set_prompting(identity, False)

def on_chat(self, chat: Chat) -> None:
"""把命中的普通聊天口令交给领取流程。"""
if not self._storage_ready:
return
raw_message = str(chat.msg)
phrase = raw_message.strip()
if is_red_packet_command(phrase):
return
if has_invalid_characters(raw_message):
chat.player.show(f"§c{INVALID_CHARACTER_MESSAGE}§r")
return
if not phrase:
return
identity = player_identity(chat.player)
with self._prompting_lock:
if identity in self._prompting_players:
return
if not self.service.has_active_phrase(phrase):
return
utils.createThread(
lambda: self.service.claim(chat.player, phrase),
usage=f"玩家 {chat.player.name} 领取红包",
)

def on_player_join(self, player: Player) -> None:
"""向上线玩家发送待投递的退款通知。"""
if self._storage_ready:
self.service.deliver_refund_notices(player)

def _expiry_loop(self) -> None:
"""周期检查红包到期状态。"""
while not self._stop_event.wait(1):
self.service.process_expired()

def _set_prompting(self, identity: str, prompting: bool) -> None:
"""更新玩家是否正在回答创建问题。"""
with self._prompting_lock:
if prompting:
self._prompting_players.add(identity)
else:
self._prompting_players.discard(identity)


entry = plugin_entry(LuckyRedPacket)
48 changes: 48 additions & 0 deletions 红包/broadcasting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""红包全服公告发送。"""

from __future__ import annotations

from typing import Any

from .messages import (
claim_message,
completed_message,
created_message,
tellraw_payload,
)
from .models import RedPacket


class RedPacketBroadcastMixin:
"""为红包插件提供全服公告方法。"""

currency_name: str
game_ctrl: Any

def broadcast_created(self, packet: RedPacket) -> None:
"""广播新创建的红包。"""
self._broadcast_raw(created_message(packet, self.currency_name))

def broadcast_claim(
self,
claimant_name: str,
sender_name: str,
amount: int,
) -> None:
"""广播玩家领取红包的结果。"""
self._broadcast_raw(
claim_message(
claimant_name,
sender_name,
amount,
self.currency_name,
)
)

def broadcast_completed(self, packet: RedPacket) -> None:
"""广播红包抢完及手气最佳结果。"""
self._broadcast_raw(completed_message(packet, self.currency_name))

def _broadcast_raw(self, message: str) -> None:
"""安全序列化并向全服发送消息。"""
self.game_ctrl.sendwocmd(f"/tellraw @a {tellraw_payload(message)}")
48 changes: 48 additions & 0 deletions 红包/configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""红包插件配置加载与校验。"""

from __future__ import annotations

from typing import Any

from tooldelta import cfg

from .text_validation import has_invalid_characters


DEFAULT_CONFIG = {
"经济计分板": "money",
"货币名称": "星尘",
"红包有效期秒数": 600,
}
CONFIG_STANDARD = {
"经济计分板": str,
"货币名称": str,
"红包有效期秒数": cfg.PInt,
}


def load_configuration(
plugin_name: str,
plugin_version: tuple[int, int, int],
) -> tuple[dict[str, Any], str, str, int]:
"""读取并规范化红包插件配置。"""
config, _ = cfg.get_plugin_config_and_version(
plugin_name,
CONFIG_STANDARD,
DEFAULT_CONFIG,
plugin_version,
)
scoreboard_name = str(config["经济计分板"]).strip()
currency_name = str(config["货币名称"]).strip()
expiry_seconds = int(config["红包有效期秒数"])
if (
not scoreboard_name
or any(character.isspace() for character in scoreboard_name)
or has_invalid_characters(scoreboard_name)
):
raise ValueError("经济计分板不能为空、包含空格或无效字符")
if not currency_name or has_invalid_characters(currency_name):
raise ValueError("货币名称不能为空或包含无效字符")
if expiry_seconds < 1:
raise ValueError("红包有效期秒数必须大于 0")
return config, scoreboard_name, currency_name, expiry_seconds
10 changes: 10 additions & 0 deletions 红包/datas.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"plugin-id": "red_packet",
"author": "Q3CC",
"version": "0.2.3",
"description": "支持全服口令领取及手气最佳播报,未领完金额自动退回。",
"plugin-type": "classic",
"pre-plugins": {
"聊天栏菜单": "0.4.1"
}
}
63 changes: 63 additions & 0 deletions 红包/economy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""经济计分板读写网关。"""

from __future__ import annotations

from typing import Any

from .models import player_identity


class ScoreboardEconomy:
"""集中封装带结果校验的计分板余额变更。"""

def __init__(self, plugin: Any) -> None:
"""保存插件实例以访问计分板和在线玩家。"""
self.plugin = plugin

def get_balance(self, player: Any) -> int | None:
"""读取玩家余额,失败时向玩家说明原因。"""
try:
return int(player.getScore(self.plugin.scoreboard_name, timeout=5))
except TimeoutError:
player.show("§c查询余额超时,请稍后重试§r")
except (ValueError, TypeError):
player.show(
f"§c无法读取经济计分板 §e「{self.plugin.scoreboard_name}」"
"§c,请联系管理员§r"
)
except Exception as err:
self.plugin.print_war(f"查询玩家 {player.name} 余额失败: {err}")
player.show("§c查询余额失败,请稍后重试§r")
return None

def change_score(self, target: str, amount: int) -> bool:
"""增减目标分数并返回命令是否成功。"""
action = "add" if amount >= 0 else "remove"
value = abs(amount)
command = (
f"/scoreboard players {action} {target} "
f"{self.plugin.scoreboard_name} {value}"
)
try:
result = self.plugin.game_ctrl.sendwscmd_with_resp(command, 5)
return bool(getattr(result, "SuccessCount", 0))
except Exception as err:
self.plugin.print_war(f"执行经济计分板命令失败: {err}")
return False

def find_online_player(self, identity: str) -> Any | None:
"""按稳定身份键查找在线玩家。"""
try:
players = self.plugin.game_ctrl.players.getAllPlayers()
except Exception:
return None
return next(
(player for player in players if player_identity(player) == identity),
None,
)

@staticmethod
def quoted_name(name: str) -> str:
"""转义并引用离线计分板玩家名。"""
escaped = name.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
Loading