311 lines
11 KiB
Python
311 lines
11 KiB
Python
"""Service layer for the crystal system."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Dict, List, Mapping, Optional, Sequence, Tuple
|
||
|
||
from PWF.Convention.Runtime.Architecture import Architecture
|
||
from PWF.Convention.Runtime.GlobalConfig import ConsoleFrontColor, ProjectConfig
|
||
from PWF.CoreModules.database import STATUS_PENDING, Database, get_db
|
||
from PWF.CoreModules.plugin_interface import DatabaseModel
|
||
|
||
from Plugins.WPSAlchemyGame import WPSAlchemyGame
|
||
from Plugins.WPSBackpackSystem import BackpackItemTier, WPSBackpackSystem
|
||
from Plugins.WPSStoreSystem import WPSStoreSystem
|
||
|
||
from .crystal_models import (
|
||
CRYSTAL_BASE_ITEM_ID,
|
||
CrystalColorDefinition,
|
||
CrystalExchangeEntry,
|
||
CrystalFinalFusion,
|
||
CrystalItemDefinition,
|
||
CrystalRecipeStage,
|
||
CrystalWaitStage,
|
||
)
|
||
|
||
|
||
CRYSTAL_TABLE_NAME = "crystal_records"
|
||
|
||
|
||
def get_crystal_db_models() -> List[DatabaseModel]:
|
||
"""Return database models required for the crystal system."""
|
||
|
||
return [
|
||
DatabaseModel(
|
||
table_name=CRYSTAL_TABLE_NAME,
|
||
column_defs={
|
||
"record_id": "INTEGER PRIMARY KEY AUTOINCREMENT",
|
||
"user_id": "INTEGER NOT NULL",
|
||
"chat_id": "INTEGER NOT NULL",
|
||
"flow_type": "TEXT NOT NULL",
|
||
"color_key": "TEXT",
|
||
"state": "TEXT NOT NULL",
|
||
"input_items": "TEXT NOT NULL",
|
||
"current_stage": "TEXT",
|
||
"expected_end_time": "TEXT",
|
||
"scheduled_task_id": "INTEGER",
|
||
"metadata": "TEXT",
|
||
"created_at": "TEXT NOT NULL",
|
||
"updated_at": "TEXT NOT NULL",
|
||
},
|
||
),
|
||
]
|
||
|
||
|
||
class CrystalService:
|
||
"""Encapsulates shared behaviours for the crystal system."""
|
||
|
||
def __init__(self) -> None:
|
||
self._db: Database = get_db()
|
||
self._config: ProjectConfig = Architecture.Get(ProjectConfig)
|
||
self._logger = self._config
|
||
|
||
# --------------------------------------------------------------------- #
|
||
# Registration helpers
|
||
# --------------------------------------------------------------------- #
|
||
|
||
def register_items(
|
||
self,
|
||
backpack: WPSBackpackSystem,
|
||
item_definitions: Mapping[str, CrystalItemDefinition],
|
||
) -> None:
|
||
"""Register crystal items into the backpack system."""
|
||
|
||
for definition in item_definitions.values():
|
||
try:
|
||
backpack.register_item(
|
||
definition.item_id,
|
||
definition.name,
|
||
definition.tier,
|
||
definition.description,
|
||
)
|
||
except Exception as exc: # pylint: disable=broad-except
|
||
self._logger.Log(
|
||
"Warning",
|
||
f"{ConsoleFrontColor.YELLOW}注册水晶物品 {definition.item_id} 失败: {exc}{ConsoleFrontColor.RESET}",
|
||
)
|
||
|
||
def register_exchange_modes(
|
||
self,
|
||
store: WPSStoreSystem,
|
||
item_definitions: Mapping[str, CrystalItemDefinition],
|
||
exchange_entries: Mapping[str, CrystalExchangeEntry],
|
||
) -> None:
|
||
"""Register barter-friendly items into store system (as metadata only)."""
|
||
|
||
for entry in exchange_entries.values():
|
||
reward_def = item_definitions.get(entry.reward_item)
|
||
if reward_def is None:
|
||
continue
|
||
metadata = {"exchange": "true", **entry.metadata}
|
||
metadata["required_items"] = json.dumps(entry.required_items)
|
||
try:
|
||
store.register_mode(
|
||
item_id=entry.reward_item,
|
||
price=1,
|
||
limit_amount=entry.reward_amount,
|
||
metadata=metadata,
|
||
)
|
||
except Exception:
|
||
# Store可能会因模式重复而抛异常,这里忽略。
|
||
continue
|
||
|
||
def register_chain_recipes(
|
||
self,
|
||
alchemy: WPSAlchemyGame,
|
||
stages: Sequence[CrystalRecipeStage],
|
||
) -> None:
|
||
"""Register crystal chain recipes into alchemy system."""
|
||
|
||
for stage in stages:
|
||
try:
|
||
alchemy.register_recipe(
|
||
stage.materials,
|
||
stage.result_item,
|
||
stage.fail_item,
|
||
stage.base_success_rate,
|
||
)
|
||
except Exception as exc: # pylint: disable=broad-except
|
||
self._logger.Log(
|
||
"Warning",
|
||
f"{ConsoleFrontColor.YELLOW}注册水晶配方 {stage.identifier} 失败: {exc}{ConsoleFrontColor.RESET}",
|
||
)
|
||
|
||
def register_final_fusion(
|
||
self,
|
||
alchemy: WPSAlchemyGame,
|
||
fusion: CrystalFinalFusion,
|
||
) -> None:
|
||
"""Register the final crystal fusion recipe."""
|
||
|
||
try:
|
||
alchemy.register_recipe(
|
||
fusion.materials,
|
||
fusion.result_item,
|
||
fusion.fail_item,
|
||
fusion.base_success_rate,
|
||
)
|
||
except Exception as exc: # pylint: disable=broad-except
|
||
self._logger.Log(
|
||
"Warning",
|
||
f"{ConsoleFrontColor.YELLOW}注册终端水晶配方 {fusion.identifier} 失败: {exc}{ConsoleFrontColor.RESET}",
|
||
)
|
||
|
||
# --------------------------------------------------------------------- #
|
||
# Wait-flow handling
|
||
# --------------------------------------------------------------------- #
|
||
|
||
def create_wait_record(
|
||
self,
|
||
color_def: CrystalColorDefinition,
|
||
wait_stage: CrystalWaitStage,
|
||
user_id: int,
|
||
chat_id: int,
|
||
) -> Tuple[int, datetime]:
|
||
"""Create a wait-flow record and enqueue scheduler task."""
|
||
|
||
now = datetime.now(timezone.utc)
|
||
expected_end = now + timedelta(minutes=wait_stage.delay_minutes)
|
||
payload = {
|
||
"color_key": color_def.color_key,
|
||
"wait_stage": wait_stage.identifier,
|
||
"produced_item": wait_stage.produced_item,
|
||
}
|
||
cursor = self._db.conn.cursor()
|
||
cursor.execute(
|
||
f"""
|
||
INSERT INTO {CRYSTAL_TABLE_NAME} (
|
||
user_id, chat_id, flow_type, color_key, state, input_items,
|
||
current_stage, expected_end_time, metadata, created_at, updated_at
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
user_id,
|
||
chat_id,
|
||
"wait_flow",
|
||
color_def.color_key,
|
||
STATUS_PENDING,
|
||
json.dumps(wait_stage.consumed_items),
|
||
wait_stage.identifier,
|
||
expected_end.isoformat(),
|
||
json.dumps(payload),
|
||
now.isoformat(),
|
||
now.isoformat(),
|
||
),
|
||
)
|
||
record_id = cursor.lastrowid
|
||
self._db.conn.commit()
|
||
return int(record_id), expected_end
|
||
|
||
def update_task_binding(self, record_id: int, task_id: Optional[int]) -> None:
|
||
"""Update the scheduled task binding for a record."""
|
||
|
||
cursor = self._db.conn.cursor()
|
||
cursor.execute(
|
||
f"UPDATE {CRYSTAL_TABLE_NAME} SET scheduled_task_id = ? WHERE record_id = ?",
|
||
(task_id, record_id),
|
||
)
|
||
self._db.conn.commit()
|
||
|
||
def mark_wait_flow_completed(self, record_id: int) -> Optional[Dict[str, str]]:
|
||
"""Scheduler callback when a wait flow is ready."""
|
||
|
||
cursor = self._db.conn.cursor()
|
||
cursor.execute(
|
||
f"""
|
||
SELECT record_id, user_id, chat_id, metadata
|
||
FROM {CRYSTAL_TABLE_NAME}
|
||
WHERE record_id = ? AND flow_type = 'wait_flow'
|
||
""",
|
||
(record_id,),
|
||
)
|
||
row = cursor.fetchone()
|
||
if not row:
|
||
return None
|
||
metadata = json.loads(row["metadata"]) if row["metadata"] else {}
|
||
produced_item = metadata.get("produced_item")
|
||
user_id = int(row["user_id"])
|
||
chat_id = int(row["chat_id"])
|
||
payload = {
|
||
"state": "completed",
|
||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
cursor.execute(
|
||
f"""
|
||
UPDATE {CRYSTAL_TABLE_NAME}
|
||
SET state = :state,
|
||
updated_at = :updated_at
|
||
WHERE record_id = :record_id
|
||
""",
|
||
{"state": "completed", "updated_at": payload["updated_at"], "record_id": record_id},
|
||
)
|
||
self._db.conn.commit()
|
||
if not produced_item:
|
||
return None
|
||
backpack: WPSBackpackSystem = Architecture.Get(WPSBackpackSystem)
|
||
try:
|
||
backpack.add_item(user_id, produced_item, 1)
|
||
except Exception as exc: # pylint: disable=broad-except
|
||
self._logger.Log(
|
||
"Error",
|
||
f"{ConsoleFrontColor.RED}发放等待产物 {produced_item} 失败: {exc}{ConsoleFrontColor.RESET}",
|
||
)
|
||
return None
|
||
return {
|
||
"produced_item": produced_item,
|
||
"user_id": str(user_id),
|
||
"chat_id": str(chat_id),
|
||
}
|
||
|
||
# --------------------------------------------------------------------- #
|
||
# Recovery
|
||
# --------------------------------------------------------------------- #
|
||
|
||
def recover_pending_wait_flows(self) -> List[Dict[str, int]]:
|
||
"""Recover pending wait-flow tasks when the plugin is reloaded."""
|
||
|
||
cursor = self._db.conn.cursor()
|
||
cursor.execute(
|
||
f"""
|
||
SELECT record_id, current_stage, expected_end_time, scheduled_task_id
|
||
FROM {CRYSTAL_TABLE_NAME}
|
||
WHERE flow_type = 'wait_flow' AND state = ?
|
||
""",
|
||
(STATUS_PENDING,),
|
||
)
|
||
rows = cursor.fetchall()
|
||
now = datetime.now(timezone.utc)
|
||
tasks: List[Dict[str, int]] = []
|
||
for row in rows:
|
||
record_id = int(row["record_id"])
|
||
expected_end = row["expected_end_time"]
|
||
scheduled_task_id = row["scheduled_task_id"]
|
||
delay_ms = 0
|
||
if expected_end:
|
||
try:
|
||
target_dt = datetime.fromisoformat(expected_end)
|
||
remaining = target_dt - now
|
||
delay_ms = max(int(remaining.total_seconds() * 1000), 0)
|
||
except ValueError:
|
||
delay_ms = 0
|
||
if scheduled_task_id:
|
||
continue
|
||
tasks.append({"record_id": record_id, "delay_ms": delay_ms})
|
||
return tasks
|
||
|
||
|
||
_CRYSTAL_SERVICE: Optional[CrystalService] = None
|
||
|
||
|
||
def get_crystal_service() -> CrystalService:
|
||
"""Return singleton crystal service instance."""
|
||
|
||
global _CRYSTAL_SERVICE
|
||
if _CRYSTAL_SERVICE is None:
|
||
_CRYSTAL_SERVICE = CrystalService()
|
||
return _CRYSTAL_SERVICE
|
||
|