diff --git a/src/classes/fortune.py b/src/classes/fortune.py index dc8e8d9..563cd2f 100644 --- a/src/classes/fortune.py +++ b/src/classes/fortune.py @@ -1,6 +1,7 @@ from __future__ import annotations import random +from enum import Enum from typing import Optional import asyncio @@ -12,6 +13,14 @@ from src.classes.action.event_helper import EventHelper from src.utils.asyncio_utils import schedule_background from src.classes.technique import TechniqueGrade, get_random_upper_technique_for_avatar from src.classes.treasure import Treasure, treasures_by_id +from src.classes.relation import Relation + + +class FortuneKind(Enum): + """奇遇类型""" + TREASURE = "treasure" + TECHNIQUE = "technique" + FIND_MASTER = "find_master" F_TREASURE_THEMES: list[str] = [ @@ -30,6 +39,14 @@ F_TECHNIQUE_THEMES: list[str] = [ "玄妙感悟", ] +F_FIND_MASTER_THEMES: list[str] = [ + "危难相救", + "品行打动", + "展露天赋", + "机缘巧合", + "通过考验", +] + def _is_rogue_and_under_equipped(avatar: Avatar) -> bool: # 必须散修;法宝为空 或 功法非上品 @@ -40,23 +57,68 @@ def _is_rogue_and_under_equipped(avatar: Avatar) -> bool: return has_no_treasure or is_tech_lower -def _choose_kind(avatar: Avatar) -> str: - # 如果无法宝,偏向法宝;否则若功法非上品,偏向功法;否则随机 - no_treasure = avatar.treasure is None - tech_not_upper = (avatar.technique is None) or (avatar.technique.grade is not TechniqueGrade.UPPER) - if no_treasure and tech_not_upper: - return random.choice(["treasure", "technique"]) # 两者都缺,随机其一 - if no_treasure: - return "treasure" - if tech_not_upper: - return "technique" - return random.choice(["treasure", "technique"]) +def _has_master(avatar: Avatar) -> bool: + """检查是否已有师傅""" + for other, rel in avatar.relations.items(): + if rel == Relation.MASTER: + return True + return False -def _pick_theme(kind: str) -> str: - if kind == "treasure": +def _find_potential_master(avatar: Avatar) -> Optional[Avatar]: + """ + 在世界中寻找潜在的师傅。 + 条件:等级 > avatar.level + 20 + """ + candidates: list[Avatar] = [] + for other in avatar.world.avatar_manager.avatars.values(): + if other is avatar: + continue + level_diff = other.cultivation_progress.level - avatar.cultivation_progress.level + if level_diff >= 20: + candidates.append(other) + + if not candidates: + return None + return random.choice(candidates) + + +def _choose_kind(avatar: Avatar) -> FortuneKind: + """ + 从所有可能的奇遇中随机选择一个。 + 可能的奇遇取决于角色当前状态。 + """ + possible_kinds: list[FortuneKind] = [] + + # 法宝奇遇:散修且无法宝 + if avatar.sect is None and avatar.treasure is None: + possible_kinds.append(FortuneKind.TREASURE) + + # 功法奇遇:散修且功法非上品 + if avatar.sect is None: + tech_not_upper = (avatar.technique is None) or (avatar.technique.grade is not TechniqueGrade.UPPER) + if tech_not_upper: + possible_kinds.append(FortuneKind.TECHNIQUE) + + # 拜师奇遇:无师傅且世界中有合适的师傅 + if not _has_master(avatar): + if _find_potential_master(avatar) is not None: + possible_kinds.append(FortuneKind.FIND_MASTER) + + if not possible_kinds: + return None + + return random.choice(possible_kinds) + + +def _pick_theme(kind: FortuneKind) -> str: + if kind == FortuneKind.TREASURE: return random.choice(F_TREASURE_THEMES) - return random.choice(F_TECHNIQUE_THEMES) + elif kind == FortuneKind.TECHNIQUE: + return random.choice(F_TECHNIQUE_THEMES) + elif kind == FortuneKind.FIND_MASTER: + return random.choice(F_FIND_MASTER_THEMES) + return "" def _get_unique_treasure_for_world(avatar: Avatar) -> Optional[Treasure]: @@ -76,33 +138,38 @@ def try_trigger_fortune(avatar: Avatar) -> list[Event]: 在月度结算阶段尝试触发奇遇。 规则: - 奇遇不是一个 action;仅在条件满足时以概率触发。 - - 触发条件:散修,且(无法宝 或 功法非上品)。 - - 结果:先决定奖励类型(法宝/功法),法宝世界唯一且不可重复;功法可重复但优先上品且需与灵根兼容。 + - 触发条件:散修且(无法宝 或 功法非上品),或者无师傅。 + - 结果:先决定奖励类型(法宝/功法/拜师),法宝世界唯一且不可重复;功法可重复但优先上品且需与灵根兼容;拜师建立师徒关系。 - 故事:仅给出主旨主题,由 LLM 自由发挥生成短故事。 """ prob = float(getattr(CONFIG.game, "fortune_probability", 0.0)) if prob <= 0.0: return [] - if not _is_rogue_and_under_equipped(avatar): - return [] + if random.random() >= prob: return [] + # 从所有可能的奇遇中选择 kind = _choose_kind(avatar) + if kind is None: + return [] + theme = _pick_theme(kind) - res_text: str = "" + related_avatars = [avatar.id] + actors_for_story = [avatar] # 用于生成故事的角色列表 - if kind == "treasure": + if kind == FortuneKind.TREASURE: tr = _get_unique_treasure_for_world(avatar) if tr is None: # 回退到功法 - kind = "technique" + kind = FortuneKind.TECHNIQUE + theme = _pick_theme(kind) else: avatar.treasure = tr res_text = f"{avatar.name} 获得法宝『{tr.name}』" - if kind == "technique": + if kind == FortuneKind.TECHNIQUE: tech = get_random_upper_technique_for_avatar(avatar) if tech is None: # 若无可用上品,则不奖励 @@ -110,24 +177,42 @@ def try_trigger_fortune(avatar: Avatar) -> list[Event]: avatar.technique = tech res_text = f"{avatar.name} 得到上品功法『{tech.name}』" + elif kind == FortuneKind.FIND_MASTER: + master = _find_potential_master(avatar) + if master is None: + # 找不到合适的师傅 + return [] + # 建立师徒关系 + avatar.set_relation(master, Relation.MASTER) + res_text = f"{avatar.name} 拜 {master.name} 为师" + related_avatars.append(master.id) + actors_for_story = [avatar, master] # 拜师奇遇需要两个人的信息 + # 生成故事(异步避免阻塞) event_text = f"遭遇奇遇({theme}),{res_text}" - story_prompt = ( - f"请据此写100~150字小故事。" - ) + story_prompt = "请据此写100~150字小故事。" month_at_finish = avatar.world.month_stamp - base_event = Event(month_at_finish, event_text, related_avatars=[avatar.id]) + base_event = Event(month_at_finish, event_text, related_avatars=related_avatars) async def _gen_and_push_story(): - story = await StoryTeller.tell_from_actors_async(event_text, res_text, avatar, prompt=story_prompt) - story_event = Event(month_at_finish, story, related_avatars=[avatar.id]) - EventHelper.push_self(story_event, avatar, to_sidebar=True) + # 拜师奇遇传入两个角色,其他奇遇传入一个角色 + story = await StoryTeller.tell_from_actors_async(event_text, res_text, *actors_for_story, prompt=story_prompt) + story_event = Event(month_at_finish, story, related_avatars=related_avatars) + # 根据涉及角色数量推送事件 + if len(actors_for_story) == 1: + EventHelper.push_self(story_event, avatar, to_sidebar=True) + else: + # 拜师奇遇涉及两个角色 + EventHelper.push_pair(story_event, initiator=avatar, target=actors_for_story[1], to_sidebar_once=True) def _fallback_sync(): - story = StoryTeller.tell_from_actors(event_text, res_text, avatar, prompt=story_prompt) - story_event = Event(month_at_finish, story, related_avatars=[avatar.id]) - EventHelper.push_self(story_event, avatar, to_sidebar=True) + story = StoryTeller.tell_from_actors(event_text, res_text, *actors_for_story, prompt=story_prompt) + story_event = Event(month_at_finish, story, related_avatars=related_avatars) + if len(actors_for_story) == 1: + EventHelper.push_self(story_event, avatar, to_sidebar=True) + else: + EventHelper.push_pair(story_event, initiator=avatar, target=actors_for_story[1], to_sidebar_once=True) schedule_background(_gen_and_push_story(), fallback=_fallback_sync) diff --git a/src/classes/mutual_action/__init__.py b/src/classes/mutual_action/__init__.py index 5baa3db..04d9efd 100644 --- a/src/classes/mutual_action/__init__.py +++ b/src/classes/mutual_action/__init__.py @@ -6,6 +6,7 @@ from .attack import Attack from .conversation import Conversation from .dual_cultivation import DualCultivation from .talk import Talk +from .impart import Impart from src.classes.action.registry import register_action __all__ = [ @@ -15,6 +16,7 @@ __all__ = [ "Conversation", "DualCultivation", "Talk", + "Impart", ] # 注册 mutual actions(均为实际动作) @@ -23,5 +25,6 @@ register_action(actual=True)(Attack) register_action(actual=True)(Conversation) register_action(actual=True)(DualCultivation) register_action(actual=True)(Talk) +register_action(actual=True)(Impart) diff --git a/src/classes/mutual_action/impart.py b/src/classes/mutual_action/impart.py new file mode 100644 index 0000000..0ae4422 --- /dev/null +++ b/src/classes/mutual_action/impart.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from .mutual_action import MutualAction +from src.classes.action.cooldown import cooldown_action +from src.classes.event import Event +from src.classes.relation import Relation +from src.utils.config import CONFIG + +if TYPE_CHECKING: + from src.classes.avatar import Avatar + + +@cooldown_action +class Impart(MutualAction): + """传道:师傅对徒弟传授修炼经验。 + + - 仅限发起方是目标的师傅(检查师徒关系) + - 师傅等级必须大于徒弟等级20级以上 + - 目标在交互范围内 + - 目标可以选择 接受 或 拒绝 + - 若接受:徒弟获得大量修为(相当于在灵气密度5的地方修炼的4倍,即2000经验) + """ + + ACTION_NAME = "传道" + COMMENT = "师傅向徒弟传授修炼经验,徒弟可获得大量修为" + DOABLES_REQUIREMENTS = "发起者是目标的师傅;师傅等级 > 徒弟等级 + 20;目标在交互范围内;不能连续执行" + PARAMS = {"target_avatar": "AvatarName"} + FEEDBACK_ACTIONS = ["Accept", "Reject"] + STORY_PROMPT: str | None = "师傅向徒弟传道授业,描绘一段温馨的师徒传承场景,体现师傅的循循善诱与徒弟的虚心求教。100~150字。" + # 传道冷却:6个月 + ACTION_CD_MONTHS: int = 6 + + def _get_template_path(self) -> Path: + return CONFIG.paths.templates / "mutual_action.txt" + + def _can_start(self, target: "Avatar") -> tuple[bool, str]: + """检查传道特有的启动条件""" + # 检查是否是师徒关系 + relation = self.avatar.get_relation(target) + if relation != Relation.APPRENTICE: + return False, "目标不是自己的徒弟" + + # 检查等级差 + level_diff = self.avatar.cultivation_progress.level - target.cultivation_progress.level + if level_diff < 20: + return False, f"等级差不足20级(当前差距:{level_diff}级)" + + return True, "" + + def start(self, target_avatar: "Avatar|str") -> Event: + target = self._get_target_avatar(target_avatar) + target_name = target.name if target is not None else str(target_avatar) + rel_ids = [self.avatar.id] + if target is not None: + rel_ids.append(target.id) + event = Event( + self.world.month_stamp, + f"{self.avatar.name} 向徒弟 {target_name} 传道授业", + related_avatars=rel_ids + ) + # 仅写入历史 + self.avatar.add_event(event, to_sidebar=False) + if target is not None: + target.add_event(event, to_sidebar=False) + # 记录开始文本用于故事生成 + self._start_event_content = event.content + # 初始化内部标记 + self._impart_success = False + self._impart_exp_gain = 0 + return event + + def _settle_feedback(self, target_avatar: "Avatar", feedback_name: str) -> None: + fb = str(feedback_name).strip() + if fb == "Accept": + # 接受则当场结算修为收益(徒弟获得) + self._apply_impart_gain(target_avatar) + self._impart_success = True + else: + # 拒绝 + self._impart_success = False + + def _apply_impart_gain(self, target: "Avatar") -> None: + # 传道经验:相当于在灵气密度5的地方修炼的4倍 + # base_exp = 100, density = 5, 倍数 = 4 + # 总经验 = 100 * 5 * 4 = 2000 + exp_gain = 100 * 5 * 4 + target.cultivation_progress.add_exp(exp_gain) + self._impart_exp_gain = exp_gain + + def finish(self, target_avatar: "Avatar|str") -> list[Event]: + target = self._get_target_avatar(target_avatar) + events: list[Event] = [] + success = self._impart_success + if target is None: + return events + + if success: + gain = int(self._impart_exp_gain) + result_text = f"{self.avatar.name} 向 {target.name} 传道,{target.name} 获得修为经验 +{gain} 点" + result_event = Event( + self.world.month_stamp, + result_text, + related_avatars=[self.avatar.id, target.id] + ) + events.append(result_event) + + # 生成师徒传道小故事 + from src.classes.story_teller import StoryTeller + start_text = self._start_event_content or result_event.content + story = StoryTeller.tell_from_actors( + start_text, + result_text, + self.avatar, + target, + prompt=self.STORY_PROMPT + ) + story_event = Event( + self.world.month_stamp, + story, + related_avatars=[self.avatar.id, target.id] + ) + events.append(story_event) + else: + result_text = f"{target.name} 婉拒了 {self.avatar.name} 的传道" + result_event = Event( + self.world.month_stamp, + result_text, + related_avatars=[self.avatar.id, target.id] + ) + events.append(result_event) + + return events +