新增菜园系统

This commit is contained in:
2025-11-10 01:15:17 +08:00
parent 22d2271bac
commit cdb3433b8a
14 changed files with 1230 additions and 15 deletions

View File

@@ -139,6 +139,12 @@ WPS Bot 插件体系,现有 `WPSConfigAPI` 提供积分管理与签到积分
- 原因:提升商店玩家出售区的可读性与一致性 - 原因:提升商店玩家出售区的可读性与一致性
- 阻碍因素:无 - 阻碍因素:无
- 状态:未确认 - 状态:未确认
2025-11-10_00:06:48
- 已修改:`Plugins/WPSStoreSystem.py`
- 更改:修正 `WPSConfigAPI.get_user_points` 参数传递,仅使用 `user_id`,避免购买流程报错。
- 原因:购买时出现 `get_user_points()` 参数数量不匹配异常。
- 阻碍因素:无
- 状态:未确认
# 最终审查 # 最终审查
(待补充) (待补充)

View File

@@ -0,0 +1,86 @@
# 背景
文件名: 2025-11-09_1_garden-system.md
创建于: 2025-11-09_22:21:42
创建者: liubai095\asus
主分支: main
任务分支: (未创建)
Yolo模式: Off
# 任务描述
现在我想要新增菜园系统, 仿照qq农场
# 项目概览
菜园系统基于PWF插件体系, 依赖现有WPSConfigSystem、WPSBackpackSystem、WPSStoreSystem、WPSFortuneSystem以及WPSAlchemyGame, 默认每位用户拥有4个可种植方块。
# 分析
- 菜园系统需基于 WPS 插件结构拆分多个入口插件:`菜园`(含子指令售出)、`种植 <种子>``收获 <格子序号>``偷取 <用户>`
- 依赖组件:`WPSConfigAPI`(积分与用户信息)、`WPSBackpackSystem`(注册与存取种子/果实/木材)、`WPSStoreSystem`(整点随机上架种子)、`WPSFortuneSystem`(获取运势值乘以 3% 修正)、`WPSAlchemyGame`(三果炼种/失败得腐败果实)、`ClockScheduler`(成熟定时通知)。
- 配置项:用户土地块数、收益倍率 `x`(默认 10、作物品类及成长参数均需从 `ProjectConfig` 读取,可写入默认值。
- 物品体系:新增 4 种普通草本(积分收益)与 3 种稀有木本木材收益名称需统一对应“XX的种子/果实/木材”额外积分≤种子价×x额外木材≤10。
- 商店:种子仅作为整点刷新的系统商品;成熟果实出售时单价=种子价×x通过商店或菜园子指令对接。
- 偷取:同一成熟方块仅可被任意用户各偷一次,剩余果实≤一半时不可再偷。
# 提议的解决方案
(待补充)
# 当前执行步骤:"4. 实施指令插件与集成"
# 任务进度
2025-11-09_23:06:20
- 已修改Plugins/WPSGardenSystem/*
- 更改:创建菜园数据模型、服务逻辑、基础插件架构以及主要指令插件;注册物品、商店模式与炼金配方,接入调度与运势配置。
- 原因:实现菜园系统核心功能与用户交互入口。
- 阻碍因素:无
- 状态:未确认
2025-11-10_00:24:00
- 已修改Plugins/WPSGardenSystem/garden_models.py
- 更改:将全部作物的成长时间缩短为原设置的六分之一,以适配更快节奏。
- 原因:游戏周期需求调整。
- 阻碍因素:无
- 状态:未确认
2025-11-10_00:30:00
- 已修改Plugins/WPSGardenSystem/garden_plugin_base.py
- 更改:优化菜园概览展示,生长中的作物不再显示剩余果实/被偷次数并将时间改为“YYYY年MM月DD日 HH时MM分SS秒”格式。
- 原因:提高信息可读性并符合生长状态逻辑。
- 阻碍因素:无
- 状态:未确认
2025-11-10_00:36:00
- 已修改Plugins/WPSGardenSystem/garden_service.py Plugins/WPSGardenSystem/garden_plugin_remove.py Plugins/WPSGardenSystem/__init__.py Plugins/WPSGardenSystem/garden_plugin_base.py
- 更改:新增 `铲除` 指令以立即清空指定地块,服务层提供对应清理接口并更新帮助文案。
- 原因:支持手动放弃正在生长或已完成的作物。
- 阻碍因素:无
- 状态:未确认
2025-11-10_00:51:39
- 已修改Plugins/WPSGardenSystem/garden_service.py Plugins/WPSGardenSystem/garden_plugin_base.py Plugins/WPSGardenSystem/garden_plugin_plant.py
- 更改:统一使用本地时间存储与展示菜园时间,并提供统一格式化函数,修复预计成熟时间显示不正确问题。
- 原因:与 ProjectConfig 日志一致,确保用户看到准确的本地时间。
- 阻碍因素:无
- 状态:未确认
2025-11-10_00:56:34
- 已修改Plugins/WPSGardenSystem/garden_service.py
- 更改:当 debug 标志开启时,种植后立即设置作物成熟并跳过计时任务,便于调试。
- 原因:加速开发环境验证流程。
- 阻碍因素:无
- 状态:未确认
2025-11-10_01:03:33
- 已修改Plugins/WPSGardenSystem/garden_service.py
- 更改:调整 debug 模式逻辑为注册零延迟的调度任务,保持成熟提醒链路,验证调度系统。
- 原因:在调试时仍需测试调度器推送流程。
- 阻碍因素:无
- 状态:未确认
2025-11-10_01:08:37
- 已修改Plugins/WPSGardenSystem/garden_service.py
- 更改:时间展示改为到分钟级,避免显示秒数以贴合调度频率。
- 原因:输出信息更简洁,与调度粒度一致。
- 阻碍因素:无
- 状态:未确认
# 最终审查
(待补充)

View File

@@ -15,6 +15,11 @@
"plugin_dir": "Plugins", "plugin_dir": "Plugins",
"alchemy_fortune_coeff": 0.03, "alchemy_fortune_coeff": 0.03,
"store_hourly_count": 5, "store_hourly_count": 5,
"garden_max_plots_per_user": 4,
"garden_sale_multiplier": 10,
"garden_fortune_coeff": 0.03,
"garden_theft_threshold_ratio": 0.5,
"garden_seed_store_limit": 5,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8000, "port": 8000,
"verbose": false, "verbose": false,

View File

@@ -306,7 +306,7 @@ class WPSAlchemyGame(WPSAPI):
details = [] details = []
for item_id, count in rewards.items(): for item_id, count in rewards.items():
try: try:
definition = backpack._get_definition(item_id) # type: ignore[attr-defined] definition = backpack._get_definition(item_id)
item_name = definition.name item_name = definition.name
except Exception: except Exception:
item_name = item_id item_name = item_id
@@ -357,7 +357,7 @@ class WPSAlchemyGame(WPSAPI):
item_id = row["item_id"] if row else identifier.strip() item_id = row["item_id"] if row else identifier.strip()
backpack: WPSBackpackSystem = Architecture.Get(WPSBackpackSystem) backpack: WPSBackpackSystem = Architecture.Get(WPSBackpackSystem)
try: try:
return backpack._get_definition(item_id) # type: ignore[attr-defined] return backpack._get_definition(item_id)
except Exception: except Exception:
return None return None

View File

@@ -0,0 +1,23 @@
from .garden_models import (
GARDEN_CROPS,
GardenCropDefinition,
GardenExtraReward,
)
from .garden_service import GardenService
from .garden_plugin_view import WPSGardenView
from .garden_plugin_plant import WPSGardenPlant
from .garden_plugin_harvest import WPSGardenHarvest
from .garden_plugin_steal import WPSGardenSteal
from .garden_plugin_remove import WPSGardenRemove
__all__ = [
"GardenCropDefinition",
"GardenExtraReward",
"GARDEN_CROPS",
"GardenService",
"WPSGardenView",
"WPSGardenPlant",
"WPSGardenHarvest",
"WPSGardenSteal",
"WPSGardenRemove",
]

View File

@@ -0,0 +1,179 @@
"""Garden system crop definitions and configuration models."""
from __future__ import annotations
from typing import Dict, List, Tuple
from PWF.Convention.Runtime.Architecture import Architecture
from PWF.Convention.Runtime.GlobalConfig import ConsoleFrontColor, ProjectConfig
from PWF.CoreModules.plugin_interface import DatabaseModel
from pydantic import BaseModel, Field
# Shared logger/config
_config: ProjectConfig = Architecture.Get(ProjectConfig)
_config.SaveProperties()
class GardenExtraReward(BaseModel):
kind: str = Field(..., description="points 或 item")
payload: Dict[str, int] = Field(default_factory=dict)
base_rate: float = Field(..., ge=0.0, le=1.0)
class Config:
allow_mutation = False
class GardenCropDefinition(BaseModel):
seed_id: str
fruit_id: str
display_name: str
tier: str # common / rare
growth_minutes: int
seed_price: int
base_yield: int
extra_reward: GardenExtraReward
extra_item_id: str | None = None
class Config:
allow_mutation = False
GARDEN_CONFIG_DEFAULTS: Dict[str, int | float] = {
"garden_max_plots_per_user": 4,
"garden_sale_multiplier": 10,
"garden_fortune_coeff": 0.03,
"garden_theft_threshold_ratio": 0.5,
"garden_seed_store_limit": 5,
}
for key, value in GARDEN_CONFIG_DEFAULTS.items():
_config.FindItem(key, value)
_config.SaveProperties()
COMMON_HERB_CROPS: Tuple[GardenCropDefinition, ...] = (
GardenCropDefinition(
seed_id="garden_seed_mint",
fruit_id="garden_fruit_mint",
display_name="薄荷",
tier="common",
growth_minutes=30,
seed_price=30,
base_yield=4,
extra_reward=GardenExtraReward(kind="points", payload={"min": 10, "max": 120}, base_rate=0.6),
),
GardenCropDefinition(
seed_id="garden_seed_basil",
fruit_id="garden_fruit_basil",
display_name="罗勒",
tier="common",
growth_minutes=40,
seed_price=36,
base_yield=5,
extra_reward=GardenExtraReward(kind="points", payload={"min": 15, "max": 150}, base_rate=0.55),
),
GardenCropDefinition(
seed_id="garden_seed_sage",
fruit_id="garden_fruit_sage",
display_name="鼠尾草",
tier="common",
growth_minutes=50,
seed_price=42,
base_yield=5,
extra_reward=GardenExtraReward(kind="points", payload={"min": 20, "max": 180}, base_rate=0.5),
),
GardenCropDefinition(
seed_id="garden_seed_rosemary",
fruit_id="garden_fruit_rosemary",
display_name="迷迭香",
tier="common",
growth_minutes=60,
seed_price=50,
base_yield=6,
extra_reward=GardenExtraReward(kind="points", payload={"min": 30, "max": 220}, base_rate=0.45),
),
)
RARE_TREE_CROPS: Tuple[GardenCropDefinition, ...] = (
GardenCropDefinition(
seed_id="garden_seed_ginkgo",
fruit_id="garden_fruit_ginkgo",
display_name="银杏",
tier="rare",
growth_minutes=120,
seed_price=120,
base_yield=3,
extra_reward=GardenExtraReward(kind="item", payload={"min": 2, "max": 6}, base_rate=0.5),
extra_item_id="garden_wood_ginkgo",
),
GardenCropDefinition(
seed_id="garden_seed_sakura",
fruit_id="garden_fruit_sakura",
display_name="樱花",
tier="rare",
growth_minutes=160,
seed_price=150,
base_yield=3,
extra_reward=GardenExtraReward(kind="item", payload={"min": 3, "max": 8}, base_rate=0.45),
extra_item_id="garden_wood_sakura",
),
GardenCropDefinition(
seed_id="garden_seed_maple",
fruit_id="garden_fruit_maple",
display_name="红枫",
tier="rare",
growth_minutes=180,
seed_price=180,
base_yield=4,
extra_reward=GardenExtraReward(kind="item", payload={"min": 4, "max": 10}, base_rate=0.4),
extra_item_id="garden_wood_maple",
),
)
GARDEN_CROPS: Dict[str, GardenCropDefinition] = {
crop.seed_id: crop for crop in (*COMMON_HERB_CROPS, *RARE_TREE_CROPS)
}
GARDEN_FRUITS: Dict[str, GardenCropDefinition] = {
crop.fruit_id: crop for crop in (*COMMON_HERB_CROPS, *RARE_TREE_CROPS)
}
GARDEN_MISC_ITEMS = {
"garden_item_rot_fruit": {
"name": "腐败的果实",
"tier": "common",
}
}
def get_garden_db_models() -> List[DatabaseModel]:
return [
DatabaseModel(
table_name="garden_plots",
column_defs={
"user_id": "INTEGER NOT NULL",
"chat_id": "INTEGER NOT NULL",
"plot_index": "INTEGER NOT NULL",
"seed_id": "TEXT NOT NULL",
"seed_quality": "TEXT NOT NULL DEFAULT 'common'",
"planted_at": "TEXT NOT NULL",
"mature_at": "TEXT NOT NULL",
"is_mature": "INTEGER NOT NULL DEFAULT 0",
"base_yield": "INTEGER NOT NULL",
"extra_type": "TEXT",
"extra_payload": "TEXT",
"remaining_fruit": "INTEGER NOT NULL",
"theft_users": "TEXT DEFAULT '[]'",
"scheduled_task_id": "INTEGER",
"PRIMARY KEY (user_id, plot_index)": "",
},
),
]
__all__ = [
"GardenCropDefinition",
"GardenExtraReward",
"GARDEN_CROPS",
"GARDEN_FRUITS",
"GARDEN_MISC_ITEMS",
"get_garden_db_models",
]

View File

@@ -0,0 +1,222 @@
"""Shared base class for garden plugins."""
from __future__ import annotations
import json
from typing import List, Optional, Type
from PWF.Convention.Runtime.Architecture import Architecture
from PWF.Convention.Runtime.GlobalConfig import ConsoleFrontColor, ProjectConfig
from PWF.CoreModules.plugin_interface import DatabaseModel
from Plugins.WPSAPI import WPSAPI
from Plugins.WPSBackpackSystem import (
BackpackItemTier,
WPSBackpackSystem,
)
from Plugins.WPSStoreSystem import WPSStoreSystem
from Plugins.WPSConfigSystem import WPSConfigAPI
from Plugins.WPSFortuneSystem import WPSFortuneSystem
from Plugins.WPSAlchemyGame import WPSAlchemyGame
from .garden_models import (
GARDEN_CROPS,
GARDEN_FRUITS,
GARDEN_MISC_ITEMS,
GardenCropDefinition,
get_garden_db_models,
)
from .garden_service import GardenService
class WPSGardenBase(WPSAPI):
_service: GardenService | None = None
_initialized: bool = False
@classmethod
def service(cls) -> GardenService:
if cls._service is None:
cls._service = GardenService()
cls._service.recover_overdue_plots()
return cls._service
def dependencies(self) -> List[Type]:
return [
WPSConfigAPI,
WPSBackpackSystem,
WPSStoreSystem,
WPSFortuneSystem,
WPSAlchemyGame,
]
def register_db_model(self) -> List[DatabaseModel]:
return get_garden_db_models()
def wake_up(self) -> None:
if WPSGardenBase._initialized:
return
WPSGardenBase._initialized = True
logger: ProjectConfig = Architecture.Get(ProjectConfig)
backpack: WPSBackpackSystem = Architecture.Get(WPSBackpackSystem)
store: WPSStoreSystem = Architecture.Get(WPSStoreSystem)
alchemy: WPSAlchemyGame = Architecture.Get(WPSAlchemyGame)
service = self.service()
for crop in GARDEN_CROPS.values():
seed_name = f"{crop.display_name}的种子"
fruit_name = f"{crop.display_name}的果实"
tier = BackpackItemTier.COMMON if crop.tier == "common" else BackpackItemTier.RARE
self._safe_register_item(backpack, crop.seed_id, seed_name, tier)
self._safe_register_item(backpack, crop.fruit_id, fruit_name, tier)
if crop.extra_reward and crop.extra_reward.kind == "item" and crop.extra_item_id:
wood_name = f"{crop.display_name}的木材"
self._safe_register_item(backpack, crop.extra_item_id, wood_name, BackpackItemTier.RARE)
self._safe_register_mode(
store,
crop,
limit_amount=service.config.seed_store_limit,
)
self._safe_register_recipe(alchemy, crop)
for item_id, meta in GARDEN_MISC_ITEMS.items():
self._safe_register_item(
backpack,
item_id,
meta["name"],
BackpackItemTier.COMMON,
)
logger.Log(
"Info",
f"{ConsoleFrontColor.GREEN}WPSGarden 系统完成物品与商店初始化{ConsoleFrontColor.RESET}",
)
# region Helpers
def _safe_register_item(
self,
backpack: WPSBackpackSystem,
item_id: str,
name: str,
tier: BackpackItemTier,
) -> None:
try:
backpack.register_item(item_id, name, tier)
except Exception:
pass
def _safe_register_mode(
self,
store: WPSStoreSystem,
crop: GardenCropDefinition,
*,
limit_amount: int,
) -> None:
try:
store.register_mode(
item_id=crop.seed_id,
price=crop.seed_price,
limit_amount=limit_amount,
)
except Exception:
pass
def _safe_register_recipe(
self,
alchemy: WPSAlchemyGame,
crop: GardenCropDefinition,
) -> None:
try:
success_rate = 0.75 if crop.tier == "common" else 0.6
alchemy.register_recipe(
(crop.fruit_id, crop.fruit_id, crop.fruit_id),
crop.seed_id,
"garden_item_rot_fruit",
success_rate,
)
except Exception:
pass
async def _clock_mark_mature(self, user_id: int, chat_id: int, plot_index: int) -> None:
service = self.service()
plot = service.get_plot(user_id, plot_index)
if not plot:
return
if int(plot["is_mature"]) == 1:
return
service.mark_mature(user_id, plot_index)
crop = GARDEN_CROPS.get(plot["seed_id"])
if crop is None:
return
message = (
"# 🌾 作物成熟提醒\n"
f"- 地块 {plot_index}{crop.display_name} 已成熟,记得收获!"
)
await self.send_markdown_message(message, chat_id, user_id)
def _format_timestamp(self, ts: str) -> str:
return self.service().format_display_time(ts)
def resolve_seed_id(self, keyword: str) -> Optional[GardenCropDefinition]:
key = keyword.strip().lower()
for crop in GARDEN_CROPS.values():
if crop.seed_id.lower() == key:
return crop
if crop.display_name.lower() == key:
return crop
if f"{crop.display_name}的种子".lower() == key:
return crop
return None
def resolve_fruit_id(self, keyword: str) -> Optional[GardenCropDefinition]:
key = keyword.strip().lower()
for crop in GARDEN_FRUITS.values():
if crop.fruit_id.lower() == key:
return crop
if crop.display_name.lower() == key:
return crop
if f"{crop.display_name}的果实".lower() == key:
return crop
return None
def format_garden_overview(self, user_id: int) -> str:
service = self.service()
plots = service.list_plots(user_id)
config = service.config
lines = ["# 🌱 菜园概览"]
if not plots:
lines.append("> 尚未种植任何作物,使用 `种植 <种子>` 开始耕种。")
else:
for plot in plots:
crop = GARDEN_CROPS.get(plot["seed_id"], None)
name = crop.display_name if crop else plot["seed_id"]
idx = plot["plot_index"]
is_mature = bool(plot["is_mature"])
mature_at = plot["mature_at"]
formatted_time = self._format_timestamp(mature_at)
if is_mature:
remaining = plot["remaining_fruit"]
theft_users = len(json.loads(plot["theft_users"])) if plot.get("theft_users") else 0
status = f"✅ 已成熟(成熟于 {formatted_time}"
lines.append(
f"- 地块 {idx}{name}{status}|剩余果实 {remaining}|被偷次数 {theft_users}"
)
else:
status = f"⌛ 生长中,预计成熟 {formatted_time}"
lines.append(f"- 地块 {idx}{name}{status}")
available = config.max_plots - len(plots)
if available > 0:
lines.append(f"\n> 尚有 {available} 块空地可用。")
lines.append(
"\n---\n- `种植 <种子>`:消耗种子种下作物\n"
"- `收获 <地块序号>`:收成成熟作物\n"
"- `偷取 <用户> <地块序号>`:从他人成熟作物中偷取果实\n"
"- `铲除 <地块序号>`:立即清空指定地块\n"
"- `菜园 售出 <果实> <数量>`:出售果实换取积分"
)
return "\n".join(lines)
# endregion
__all__ = ["WPSGardenBase"]

View File

@@ -0,0 +1,78 @@
"""Harvest plugin for garden system."""
from __future__ import annotations
from typing import Optional
from PWF.Convention.Runtime.Architecture import Architecture
from Plugins.WPSBackpackSystem import WPSBackpackSystem
from Plugins.WPSConfigSystem import WPSConfigAPI
from Plugins.WPSFortuneSystem import WPSFortuneSystem
from .garden_plugin_base import WPSGardenBase
class WPSGardenHarvest(WPSGardenBase):
def wake_up(self) -> None:
super().wake_up()
self.register_plugin("harvest")
self.register_plugin("收获")
async def callback(self, message: str, chat_id: int, user_id: int) -> Optional[str]:
payload = self.parse_message_after_at(message).strip()
if not payload:
return await self.send_markdown_message("❌ 指令格式:`收获 <地块序号>`", chat_id, user_id)
tokens = [token.strip() for token in payload.split() if token.strip()]
if not tokens or not tokens[0].isdigit():
return await self.send_markdown_message("❌ 指令格式:`收获 <地块序号>`", chat_id, user_id)
plot_index = int(tokens[0])
fortune: WPSFortuneSystem = Architecture.Get(WPSFortuneSystem)
fortune_value = fortune.get_fortune_value(user_id)
try:
result = self.service().harvest(
user_id=user_id,
plot_index=plot_index,
fortune_value=fortune_value,
)
except ValueError as exc:
return await self.send_markdown_message(f"{exc}", chat_id, user_id)
crop = result["crop"]
base_qty = result["base_yield"]
backpack: WPSBackpackSystem = Architecture.Get(WPSBackpackSystem)
backpack.add_item(user_id, crop.fruit_id, base_qty)
config_api: WPSConfigAPI = Architecture.Get(WPSConfigAPI)
extra_lines = []
if result["extra"]:
extra = result["extra"]
if extra["type"] == "points":
gained = int(extra["amount"])
if gained > 0:
new_points = await config_api.adjust_user_points(
chat_id,
user_id,
gained,
reason=f"收获 {crop.display_name} 的额外积分",
)
extra_lines.append(f"- 额外积分:+{gained}(当前积分 {new_points}")
elif extra["type"] == "item":
item_id = extra["item_id"]
qty = int(extra["quantity"])
if qty > 0:
backpack.add_item(user_id, item_id, qty)
extra_lines.append(f"- 额外物品:{item_id} × {qty}")
message_lines = [
"# ✅ 收获成功",
f"- 地块:{plot_index}",
f"- 作物:{crop.display_name}",
f"- 基础果实:{crop.display_name}的果实 × {base_qty}",
]
message_lines.extend(extra_lines)
return await self.send_markdown_message("\n".join(message_lines), chat_id, user_id)
__all__ = ["WPSGardenHarvest"]

View File

@@ -0,0 +1,72 @@
"""Planting plugin for garden system."""
from __future__ import annotations
from typing import Optional
from PWF.Convention.Runtime.Architecture import Architecture
from Plugins.WPSBackpackSystem import WPSBackpackSystem
from .garden_plugin_base import WPSGardenBase
class WPSGardenPlant(WPSGardenBase):
def wake_up(self) -> None:
super().wake_up()
self.register_plugin("plant")
self.register_plugin("种植")
async def callback(self, message: str, chat_id: int, user_id: int) -> Optional[str]:
payload = self.parse_message_after_at(message).strip()
if not payload:
return await self.send_markdown_message("❌ 指令格式:`种植 <种子> [地块序号]`", chat_id, user_id)
tokens = [token.strip() for token in payload.split() if token.strip()]
if not tokens:
return await self.send_markdown_message("❌ 指令格式:`种植 <种子> [地块序号]`", chat_id, user_id)
plot_index: Optional[int] = None
if len(tokens) >= 2 and tokens[-1].isdigit():
plot_index = int(tokens[-1])
identifier = " ".join(tokens[:-1])
else:
identifier = " ".join(tokens)
crop = self.resolve_seed_id(identifier)
if crop is None:
return await self.send_markdown_message("❌ 未找到对应种子", chat_id, user_id)
backpack: WPSBackpackSystem = Architecture.Get(WPSBackpackSystem)
owned = 0
for item in backpack.get_user_items(user_id):
if item.item_id == crop.seed_id:
owned = item.quantity
break
if owned <= 0:
return await self.send_markdown_message("❌ 背包中没有该种子", chat_id, user_id)
try:
assigned_plot, mature_at_iso = self.service().plant(
user_id=user_id,
chat_id=chat_id,
seed_id=crop.seed_id,
plot_index=plot_index,
register_callback=(self, "_clock_mark_mature"),
)
except ValueError as exc:
return await self.send_markdown_message(f"{exc}", chat_id, user_id)
backpack.set_item_quantity(user_id, crop.seed_id, owned - 1)
maturity_label = self.service().format_display_time(mature_at_iso)
message_body = (
"# 🌱 种植成功\n"
f"- 地块:{assigned_plot}\n"
f"- 作物:{crop.display_name}\n"
f"- 预计成熟:{maturity_label}"
)
return await self.send_markdown_message(message_body, chat_id, user_id)
__all__ = ["WPSGardenPlant"]

View File

@@ -0,0 +1,39 @@
"""Remove (clear) plot plugin for garden system."""
from __future__ import annotations
from typing import Optional
from .garden_plugin_base import WPSGardenBase
class WPSGardenRemove(WPSGardenBase):
def wake_up(self) -> None:
super().wake_up()
self.register_plugin("remove")
self.register_plugin("铲除")
async def callback(self, message: str, chat_id: int, user_id: int) -> Optional[str]:
payload = self.parse_message_after_at(message).strip()
if not payload:
return await self.send_markdown_message("❌ 指令格式:`铲除 <地块序号>`", chat_id, user_id)
tokens = [token.strip() for token in payload.split() if token.strip()]
if not tokens or not tokens[0].isdigit():
return await self.send_markdown_message("❌ 指令格式:`铲除 <地块序号>`", chat_id, user_id)
plot_index = int(tokens[0])
if plot_index <= 0:
return await self.send_markdown_message("❌ 地块序号必须为正整数", chat_id, user_id)
success = self.service().clear_plot(user_id=user_id, plot_index=plot_index)
if not success:
return await self.send_markdown_message("❌ 指定地块不存在或已为空", chat_id, user_id)
message_body = (
"# 🧹 铲除完成\n"
f"- 已清空地块 {plot_index}\n"
"- 可以重新种植新的作物"
)
return await self.send_markdown_message(message_body, chat_id, user_id)
__all__ = ["WPSGardenRemove"]

View File

@@ -0,0 +1,86 @@
"""Steal plugin for garden system."""
from __future__ import annotations
from typing import Optional
from PWF.Convention.Runtime.Architecture import Architecture
from PWF.CoreModules.database import get_db
from Plugins.WPSBackpackSystem import WPSBackpackSystem
from .garden_plugin_base import WPSGardenBase
class WPSGardenSteal(WPSGardenBase):
def wake_up(self) -> None:
super().wake_up()
self.register_plugin("steal")
self.register_plugin("偷取")
async def callback(self, message: str, chat_id: int, user_id: int) -> Optional[str]:
payload = self.parse_message_after_at(message).strip()
if not payload:
return await self.send_markdown_message("❌ 指令格式:`偷取 <用户> <地块序号>`", chat_id, user_id)
tokens = [token.strip() for token in payload.split() if token.strip()]
if len(tokens) < 2:
return await self.send_markdown_message("❌ 指令格式:`偷取 <用户> <地块序号>`", chat_id, user_id)
target_identifier = tokens[0]
if not tokens[1].isdigit():
return await self.send_markdown_message("❌ 指令格式:`偷取 <用户> <地块序号>`", chat_id, user_id)
plot_index = int(tokens[1])
owner_id = self._resolve_user_identifier(target_identifier)
if owner_id is None:
return await self.send_markdown_message("❌ 未找到目标用户", chat_id, user_id)
if owner_id == user_id:
return await self.send_markdown_message("❌ 不能偷取自己的菜园", chat_id, user_id)
try:
result = self.service().steal(
thief_id=user_id,
owner_id=owner_id,
plot_index=plot_index,
)
except ValueError as exc:
return await self.send_markdown_message(f"{exc}", chat_id, user_id)
crop = result["crop"]
backpack: WPSBackpackSystem = Architecture.Get(WPSBackpackSystem)
backpack.add_item(user_id, crop.fruit_id, result["stolen_quantity"])
remaining = result["remaining"]
message = (
"# 🕵️ 偷取成功\n"
f"- 目标:{crop.display_name}\n"
f"- 获得:{crop.display_name}的果实 × {result['stolen_quantity']}\n"
f"- 目标剩余果实:{remaining}"
)
await self.send_markdown_message(message, chat_id, user_id)
owner_chat = result.get("chat_id")
if owner_chat:
owner_message = (
"# ⚠️ 菜园警报\n"
f"- 你的 {crop.display_name} 被偷走了 1 个果实\n"
f"- 当前剩余果实:{remaining}"
)
await self.send_markdown_message(owner_message, owner_chat, owner_id)
return None
def _resolve_user_identifier(self, identifier: str) -> Optional[int]:
text = identifier.strip()
if text.isdigit():
return int(text)
cursor = get_db().conn.cursor()
cursor.execute(
"SELECT user_id FROM user_info WHERE username = ? COLLATE NOCASE",
(text,),
)
row = cursor.fetchone()
if row:
return int(row["user_id"])
return None
__all__ = ["WPSGardenSteal"]

View File

@@ -0,0 +1,89 @@
"""Garden overview and selling plugin."""
from __future__ import annotations
from typing import Optional
from PWF.Convention.Runtime.Architecture import Architecture
from Plugins.WPSBackpackSystem import WPSBackpackSystem
from Plugins.WPSConfigSystem import WPSConfigAPI
from .garden_plugin_base import WPSGardenBase
class WPSGardenView(WPSGardenBase):
def wake_up(self) -> None:
super().wake_up()
self.register_plugin("garden")
self.register_plugin("菜园")
async def callback(self, message: str, chat_id: int, user_id: int) -> Optional[str]:
payload = self.parse_message_after_at(message).strip()
if not payload:
return await self._send_overview(chat_id, user_id)
tokens = [token.strip() for token in payload.split() if token.strip()]
if tokens and tokens[0] in {"售出", "sell"}:
return await self._handle_sell(tokens[1:], chat_id, user_id)
return await self._send_overview(chat_id, user_id)
async def _send_overview(self, chat_id: int, user_id: int) -> Optional[str]:
overview = self.format_garden_overview(user_id)
return await self.send_markdown_message(overview, chat_id, user_id)
async def _handle_sell(self, args: list[str], chat_id: int, user_id: int) -> Optional[str]:
if len(args) < 2:
return await self.send_markdown_message(
"❌ 指令格式:`菜园 售出 <果实> <数量>`",
chat_id,
user_id,
)
identifier = args[0]
try:
quantity = int(args[1])
except ValueError:
return await self.send_markdown_message("❌ 数量必须是整数", chat_id, user_id)
if quantity <= 0:
return await self.send_markdown_message("❌ 数量必须大于0", chat_id, user_id)
crop = self.resolve_fruit_id(identifier)
if crop is None:
return await self.send_markdown_message("❌ 未找到对应果实", chat_id, user_id)
backpack: WPSBackpackSystem = Architecture.Get(WPSBackpackSystem)
owned = 0
for item in backpack.get_user_items(user_id):
if item.item_id == crop.fruit_id:
owned = item.quantity
break
if owned < quantity:
return await self.send_markdown_message("❌ 果实数量不足", chat_id, user_id)
total_points, price_per = self.service().sell_fruit(
user_id=user_id,
fruit_id=crop.fruit_id,
quantity=quantity,
)
backpack.set_item_quantity(user_id, crop.fruit_id, owned - quantity)
config_api: WPSConfigAPI = Architecture.Get(WPSConfigAPI)
new_points = await config_api.adjust_user_points(
chat_id,
user_id,
total_points,
reason=f"出售 {crop.display_name} 的果实",
)
message = (
"# 🛒 售出成功\n"
f"- 果实:{crop.display_name} × {quantity}\n"
f"- 单价:{price_per}\n"
f"- 总计:{total_points}\n"
f"- 当前积分:{new_points}"
)
return await self.send_markdown_message(message, chat_id, user_id)
__all__ = ["WPSGardenView"]

View File

@@ -0,0 +1,330 @@
"""Garden service handling planting, harvesting, stealing, and selling."""
from __future__ import annotations
import json
import random
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from PWF.Convention.Runtime.Architecture import Architecture
from PWF.Convention.Runtime.GlobalConfig import ConsoleFrontColor, ProjectConfig
from PWF.CoreModules.database import get_db
from PWF.CoreModules.plugin_interface import PluginInterface
from PWF.CoreModules.flags import get_internal_debug
from pydantic import BaseModel
from .garden_models import (
GARDEN_CROPS,
GARDEN_FRUITS,
GARDEN_MISC_ITEMS,
GardenCropDefinition,
get_garden_db_models,
)
Timestamp = str
def _local_now() -> datetime:
return datetime.now()
def _parse_local_iso(ts: str) -> datetime:
return datetime.fromisoformat(ts)
class GardenConfig(BaseModel):
max_plots: int
sale_multiplier: int
fortune_coeff: float
theft_threshold_ratio: float
seed_store_limit: int
class Config:
allow_mutation = False
@classmethod
def load(cls) -> "GardenConfig":
project_config: ProjectConfig = Architecture.Get(ProjectConfig)
max_plots = int(project_config.FindItem("garden_max_plots_per_user", 4))
sale_multiplier = int(project_config.FindItem("garden_sale_multiplier", 10))
fortune_coeff = float(project_config.FindItem("garden_fortune_coeff", 0.03))
theft_ratio = float(project_config.FindItem("garden_theft_threshold_ratio", 0.5))
seed_store_limit = int(project_config.FindItem("garden_seed_store_limit", 5))
project_config.SaveProperties()
return cls(
max_plots=max_plots,
sale_multiplier=sale_multiplier,
fortune_coeff=fortune_coeff,
theft_threshold_ratio=theft_ratio,
seed_store_limit=seed_store_limit,
)
class GardenService:
def __init__(self) -> None:
self._config = GardenConfig.load()
self._db = get_db()
self._logger: ProjectConfig = Architecture.Get(ProjectConfig)
@property
def config(self) -> GardenConfig:
return self._config
# region Query helpers
def list_plots(self, user_id: int) -> List[Dict[str, object]]:
cursor = self._db.conn.cursor()
cursor.execute(
"SELECT * FROM garden_plots WHERE user_id = ? ORDER BY plot_index ASC",
(user_id,),
)
rows = cursor.fetchall()
return [dict(row) for row in rows]
def get_plot(self, user_id: int, plot_index: int) -> Optional[Dict[str, object]]:
cursor = self._db.conn.cursor()
cursor.execute(
"SELECT * FROM garden_plots WHERE user_id = ? AND plot_index = ?",
(user_id, plot_index),
)
row = cursor.fetchone()
return dict(row) if row else None
# endregion
# region Planting
def plant(
self,
*,
user_id: int,
chat_id: int,
seed_id: str,
plot_index: Optional[int] = None,
register_callback: Optional[
Tuple[PluginInterface, str]
] = None,
) -> Tuple[int, datetime]:
crop = GARDEN_CROPS.get(seed_id)
if not crop:
raise ValueError("未知的种子")
plots = self.list_plots(user_id)
used_indices = {int(plot["plot_index"]) for plot in plots}
if len(used_indices) >= self._config.max_plots:
raise ValueError("没有空闲土地")
if plot_index is None:
for idx in range(1, self._config.max_plots + 1):
if idx not in used_indices:
plot_index = idx
break
if plot_index is None:
raise ValueError("无法分配地块")
planted_at = _local_now()
mature_at = planted_at + timedelta(minutes=crop.growth_minutes)
debug_mode = get_internal_debug()
if debug_mode:
mature_at = planted_at
cursor = self._db.conn.cursor()
cursor.execute(
"""
INSERT INTO garden_plots (
user_id, chat_id, plot_index, seed_id, seed_quality, planted_at, mature_at,
is_mature, base_yield, extra_type, extra_payload, remaining_fruit, theft_users, scheduled_task_id
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, NULL)
""",
(
user_id,
chat_id,
plot_index,
crop.seed_id,
crop.tier,
planted_at.isoformat(),
mature_at.isoformat(),
crop.base_yield,
crop.extra_reward.kind,
json.dumps(crop.extra_reward.payload) if crop.extra_reward else None,
crop.base_yield,
json.dumps([]),
),
)
self._db.conn.commit()
task_id = None
if register_callback:
plugin, callback_name = register_callback
delay_ms = 0 if debug_mode else int(crop.growth_minutes * 60 * 1000)
task_id = plugin.register_clock(
getattr(plugin, callback_name),
delay_ms,
kwargs={"user_id": user_id, "chat_id": chat_id, "plot_index": plot_index},
)
cursor.execute(
"UPDATE garden_plots SET scheduled_task_id = ? WHERE user_id = ? AND plot_index = ?",
(task_id, user_id, plot_index),
)
self._db.conn.commit()
return plot_index, mature_at.isoformat()
def mark_mature(self, user_id: int, plot_index: int) -> None:
cursor = self._db.conn.cursor()
cursor.execute(
"UPDATE garden_plots SET is_mature = 1, scheduled_task_id = NULL WHERE user_id = ? AND plot_index = ?",
(user_id, plot_index),
)
self._db.conn.commit()
# endregion
# region Harvest
def harvest(self, *, user_id: int, plot_index: int, fortune_value: float) -> Dict[str, object]:
plot = self.get_plot(user_id, plot_index)
if not plot:
raise ValueError("指定地块不存在")
if int(plot["is_mature"]) != 1:
raise ValueError("作物尚未成熟")
crop = GARDEN_CROPS.get(plot["seed_id"])
if not crop:
raise ValueError("未知作物")
base_yield = int(plot["base_yield"])
extra_reward = None
if crop.extra_reward:
base_rate = crop.extra_reward.base_rate
probability = max(
0.0,
min(1.0, base_rate + fortune_value * self._config.fortune_coeff),
)
if random.random() <= probability:
if crop.extra_reward.kind == "points":
data = crop.extra_reward.payload
max_points = min(
data.get("max", base_yield * crop.seed_price),
crop.seed_price * self._config.sale_multiplier,
)
min_points = data.get("min", 0)
if max_points > 0:
amount = random.randint(min_points, max_points)
extra_reward = {"type": "points", "amount": amount}
elif crop.extra_reward.kind == "item" and crop.extra_item_id:
data = crop.extra_reward.payload
min_qty = max(0, data.get("min", 0))
max_qty = max(min_qty, data.get("max", min_qty))
if max_qty > 0:
quantity = random.randint(min_qty, max_qty)
extra_reward = {
"type": "item",
"item_id": crop.extra_item_id,
"quantity": quantity,
}
result = {
"crop": crop,
"base_yield": base_yield,
"extra": extra_reward,
}
cursor = self._db.conn.cursor()
cursor.execute(
"DELETE FROM garden_plots WHERE user_id = ? AND plot_index = ?",
(user_id, plot_index),
)
self._db.conn.commit()
return result
def clear_plot(self, *, user_id: int, plot_index: int) -> bool:
cursor = self._db.conn.cursor()
cursor.execute(
"DELETE FROM garden_plots WHERE user_id = ? AND plot_index = ?",
(user_id, plot_index),
)
deleted = cursor.rowcount > 0
if deleted:
self._db.conn.commit()
return deleted
# endregion
# region Steal
def steal(self, *, thief_id: int, owner_id: int, plot_index: int) -> Dict[str, object]:
plot = self.get_plot(owner_id, plot_index)
if not plot:
raise ValueError("目标地块不存在")
if int(plot["is_mature"]) != 1:
raise ValueError("目标作物尚未成熟")
crop = GARDEN_CROPS.get(plot["seed_id"])
if not crop:
raise ValueError("未知作物")
remaining = int(plot["remaining_fruit"])
threshold = int(round(int(plot["base_yield"]) * self._config.theft_threshold_ratio))
if remaining <= threshold:
raise ValueError("果实剩余不足,无法偷取")
theft_users = set(json.loads(plot["theft_users"]))
if thief_id in theft_users:
raise ValueError("你已经偷取过该作物")
theft_users.add(thief_id)
remaining -= 1
cursor = self._db.conn.cursor()
cursor.execute(
"""
UPDATE garden_plots SET remaining_fruit = ?, theft_users = ?
WHERE user_id = ? AND plot_index = ?
""",
(
remaining,
json.dumps(list(theft_users)),
owner_id,
plot_index,
),
)
self._db.conn.commit()
return {
"crop": crop,
"stolen_quantity": 1,
"remaining": remaining,
"chat_id": plot["chat_id"],
}
# endregion
# region Selling
def sell_fruit(self, *, user_id: int, fruit_id: str, quantity: int) -> Tuple[int, int]:
crop = GARDEN_FRUITS.get(fruit_id)
if not crop:
raise ValueError("未知果实")
if quantity <= 0:
raise ValueError("数量必须大于0")
price_per = crop.seed_price * self._config.sale_multiplier
total_points = price_per * quantity
return total_points, price_per
# endregion
# region Maintenance
def recover_overdue_plots(self) -> None:
cursor = self._db.conn.cursor()
cursor.execute(
"SELECT user_id, plot_index, mature_at, is_mature FROM garden_plots WHERE is_mature = 0",
)
rows = cursor.fetchall()
now = _local_now()
updated = 0
for row in rows:
mature_at = _parse_local_iso(row["mature_at"])
if mature_at <= now:
self.mark_mature(row["user_id"], row["plot_index"])
updated += 1
if updated:
self._logger.Log(
"Info",
f"{ConsoleFrontColor.GREEN}同步成熟地块 {updated}{ConsoleFrontColor.RESET}",
)
# endregion
# region Utilities
def format_display_time(self, iso_ts: str) -> str:
try:
dt = _parse_local_iso(iso_ts)
return dt.strftime("%Y年%m月%d%H时%M分")
except Exception:
return iso_ts
# endregion
__all__ = ["GardenService", "GardenConfig", "get_garden_db_models"]

View File

@@ -167,7 +167,7 @@ class WPSStoreSystem(WPSAPI):
backpack = Architecture.Get(WPSBackpackSystem) backpack = Architecture.Get(WPSBackpackSystem)
try: try:
item_def = backpack._get_definition(item_id) # type: ignore[attr-defined] item_def = backpack._get_definition(item_id)
except Exception as exc: except Exception as exc:
raise ValueError(f"Item {item_id} not registered in backpack system") from exc raise ValueError(f"Item {item_id} not registered in backpack system") from exc
@@ -292,7 +292,7 @@ class WPSStoreSystem(WPSAPI):
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
backpack = Architecture.Get(WPSBackpackSystem) backpack = Architecture.Get(WPSBackpackSystem)
for mode in selection: for mode in selection:
definition = backpack._get_definition(mode.item_id) # type: ignore[attr-defined] definition = backpack._get_definition(mode.item_id)
remaining = mode.limit_amount if mode.limit_amount >= 0 else -1 remaining = mode.limit_amount if mode.limit_amount >= 0 else -1
cursor.execute( cursor.execute(
f""" f"""
@@ -338,7 +338,7 @@ class WPSStoreSystem(WPSAPI):
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
backpack = Architecture.Get(WPSBackpackSystem) backpack = Architecture.Get(WPSBackpackSystem)
for mode in permanent_modes: for mode in permanent_modes:
definition = backpack._get_definition(mode.item_id) # type: ignore[attr-defined] definition = backpack._get_definition(mode.item_id)
cursor.execute( cursor.execute(
f""" f"""
INSERT INTO {self.SYSTEM_TABLE} ( INSERT INTO {self.SYSTEM_TABLE} (
@@ -621,7 +621,7 @@ class WPSStoreSystem(WPSAPI):
for row in rows: for row in rows:
item_id = row["item_id"] item_id = row["item_id"]
try: try:
definition = backpack._get_definition(item_id) # type: ignore[attr-defined] definition = backpack._get_definition(item_id)
item_name = definition.name item_name = definition.name
except Exception: except Exception:
item_name = item_id item_name = item_id
@@ -706,8 +706,8 @@ class WPSStoreSystem(WPSAPI):
user_id: int, user_id: int,
) -> str: ) -> str:
total_price = entry.price * quantity total_price = entry.price * quantity
config_api = Architecture.Get(WPSConfigAPI) config_api: WPSConfigAPI = Architecture.Get(WPSConfigAPI)
user_points = Architecture.Get(WPSConfigAPI).get_user_points(chat_id, user_id) user_points = config_api.get_user_points(user_id)
if user_points < total_price: if user_points < total_price:
return f"❌ 积分不足,需要 {total_price} 分,当前仅有 {user_points}" return f"❌ 积分不足,需要 {total_price} 分,当前仅有 {user_points}"
@@ -756,7 +756,7 @@ class WPSStoreSystem(WPSAPI):
matches.append(listing) matches.append(listing)
continue continue
try: try:
definition = backpack._get_definition(listing.item_id) # type: ignore[attr-defined] definition = backpack._get_definition(listing.item_id)
except Exception: except Exception:
continue continue
if definition.name.lower() == identifier_lower: if definition.name.lower() == identifier_lower:
@@ -779,8 +779,8 @@ class WPSStoreSystem(WPSAPI):
return "❌ 无法购买自己上架的商品" return "❌ 无法购买自己上架的商品"
total_price = listing.price * quantity total_price = listing.price * quantity
config_api = Architecture.Get(WPSConfigAPI) config_api: WPSConfigAPI = Architecture.Get(WPSConfigAPI)
buyer_points = config_api.get_user_points(chat_id, user_id) buyer_points = config_api.get_user_points(user_id)
if buyer_points < total_price: if buyer_points < total_price:
return f"❌ 积分不足,需要 {total_price} 分,当前仅有 {buyer_points}" return f"❌ 积分不足,需要 {total_price} 分,当前仅有 {buyer_points}"
@@ -818,7 +818,7 @@ class WPSStoreSystem(WPSAPI):
backpack = Architecture.Get(WPSBackpackSystem) backpack = Architecture.Get(WPSBackpackSystem)
backpack.add_item(user_id, listing.item_id, quantity) backpack.add_item(user_id, listing.item_id, quantity)
definition = backpack._get_definition(listing.item_id) # type: ignore[attr-defined] definition = backpack._get_definition(listing.item_id)
return ( return (
f"✅ 成功购买玩家商品 {definition.name} × {quantity},花费 {total_price}\n" f"✅ 成功购买玩家商品 {definition.name} × {quantity},花费 {total_price}\n"
f"当前剩余积分:{buyer_new_points}" f"当前剩余积分:{buyer_new_points}"
@@ -857,7 +857,7 @@ class WPSStoreSystem(WPSAPI):
item_id = row["item_id"] item_id = row["item_id"]
backpack: WPSBackpackSystem = Architecture.Get(WPSBackpackSystem) backpack: WPSBackpackSystem = Architecture.Get(WPSBackpackSystem)
try: try:
definition = backpack._get_definition(item_id) # type: ignore[attr-defined] definition = backpack._get_definition(item_id)
item_name = definition.name item_name = definition.name
except Exception: except Exception:
item_name = item_id item_name = item_id
@@ -902,7 +902,7 @@ class WPSStoreSystem(WPSAPI):
row = cursor.fetchone() row = cursor.fetchone()
item_id = row["item_id"] if row else identifier item_id = row["item_id"] if row else identifier
try: try:
definition = backpack._get_definition(item_id) # type: ignore[attr-defined] definition = backpack._get_definition(item_id)
return definition.item_id, definition return definition.item_id, definition
except Exception: except Exception:
return None, None return None, None
@@ -1002,7 +1002,7 @@ class WPSStoreBuyCommand(WPSAPI):
except ValueError: except ValueError:
return await self._send_error("❌ 购买数量必须是整数", chat_id, user_id) return await self._send_error("❌ 购买数量必须是整数", chat_id, user_id)
store_api = Architecture.Get(WPSStoreSystem) store_api: WPSStoreSystem = Architecture.Get(WPSStoreSystem)
response = await store_api.purchase_item( response = await store_api.purchase_item(
chat_id=chat_id, chat_id=chat_id,
user_id=user_id, user_id=user_id,