Feat/retreat (#104)

* feat: add retreat
This commit is contained in:
4thfever
2026-01-26 23:18:11 +08:00
committed by GitHub
parent 406d62f983
commit 84027fc1d7
33 changed files with 544 additions and 46 deletions

View File

@@ -39,6 +39,7 @@ from .cast import Cast
from .refine import Refine
from .buy import Buy
from .mine import Mine
from .retreat import Retreat
# 注册到 ActionRegistry标注是否为实际可执行动作
register_action(actual=False)(Action)
@@ -74,6 +75,7 @@ register_action(actual=True)(Cast)
register_action(actual=True)(Refine)
register_action(actual=True)(Buy)
register_action(actual=True)(Mine)
register_action(actual=True)(Retreat)
# Talk 已移动到 mutual_action 模块,在那里注册
__all__ = [
@@ -112,6 +114,7 @@ __all__ = [
"Refine",
"Buy",
"Mine",
"Retreat",
]

View File

@@ -55,6 +55,12 @@ class Action(ABC):
DESC_ID: str = ""
REQUIREMENTS_ID: str = ""
# 是否允许参与聚会(如拍卖会、大比)
ALLOW_GATHERING: bool = True
# 是否允许触发世界随机事件(如奇遇、霉运)
ALLOW_WORLD_EVENTS: bool = True
def __init__(self, avatar: Avatar, world: World):
"""
传一个avatar的ref

View File

@@ -0,0 +1,141 @@
from __future__ import annotations
import random
from src.i18n import t
from src.classes.action import TimedAction
from src.classes.action.cooldown import cooldown_action
from src.classes.event import Event
from src.classes.cultivation import REALM_RANK
from src.classes.action_runtime import ActionResult, ActionStatus
from src.classes.story_teller import StoryTeller
@cooldown_action
class Retreat(TimedAction):
"""
闭关:赌博性质的行为。
随机持续 1-5 年。
成功获得持续10年的突破概率加成。
失败:减少寿元。
"""
ACTION_NAME_ID = "retreat_action_name"
DESC_ID = "retreat_desc"
REQUIREMENTS_ID = "retreat_requirements"
EMOJI = "🧘"
PARAMS = {}
# 闭关结束后1年内不能再次闭关
ACTION_CD_MONTHS = 12
IS_MAJOR = True
# 闭关期间,不问世事,不染因果
ALLOW_GATHERING = False
ALLOW_WORLD_EVENTS = False
def __init__(self, avatar, world):
super().__init__(avatar, world)
# 随机持续时间12 - 60 个月 (1-5年)
self.duration_months = random.randint(12, 60)
def get_save_data(self) -> dict:
data = super().get_save_data()
data['duration_months'] = self.duration_months
return data
def load_save_data(self, data: dict) -> None:
super().load_save_data(data)
if 'duration_months' in data:
self.duration_months = data['duration_months']
def calc_success_rate(self) -> float:
"""
计算闭关成功率
练气(0): 50%, 筑基(1): 40%, 金丹(2): 30%, 元婴(3): 20%
"""
realm_idx = REALM_RANK.get(self.avatar.cultivation_progress.realm, 0)
base = 0.5 - (realm_idx * 0.1)
base = max(0.1, base)
# 应用effect加成
extra_rate = self.avatar.effects.get("extra_retreat_success_rate", 0.0)
return min(1.0, base + float(extra_rate))
def _execute(self) -> None:
# TimedAction 的 _execute 每月调用,这里主要做结束时的结算
# 但 TimedAction.step 会在时间到时将状态设为 COMPLETED
# 我们需要在 finish 中处理结算逻辑,或者在最后一次 step 中处理
# 按照 TimedAction 的设计_execute 是过程逻辑。
# 我们可以留空 _execute或者在这里加一些描述性事件可选
pass
async def finish(self) -> list[Event]:
# 1. 判定结果
success_rate = self.calc_success_rate()
is_success = random.random() < success_rate
events = []
current_month = int(self.world.month_stamp)
if is_success:
# 成功增加临时效果10年 = 120个月
buff_duration = 120
# 增加 20% 突破成功率
bonus = {
"extra_breakthrough_success_rate": 0.3
}
self.avatar.temporary_effects.append({
"source": "Retreat Bonus",
"effects": bonus,
"start_month": current_month,
"duration": buff_duration
})
self.avatar.recalc_effects()
result_text = t("retreat_success", duration=self.duration_months)
core_text = t("{avatar} finished retreat successfully.", avatar=self.avatar.name)
# 生成故事
prompt = t("retreat_story_prompt_success")
story = await StoryTeller.tell_story(core_text, result_text, self.avatar, prompt=prompt)
events.append(Event(self.world.month_stamp, core_text, related_avatars=[self.avatar.id], is_major=True))
events.append(Event(self.world.month_stamp, story, related_avatars=[self.avatar.id], is_story=True))
else:
# 失败:扣除寿元
# 随机扣除 5-20 年
reduce_years = random.randint(5, 20)
self.avatar.age.decrease_max_lifespan(reduce_years)
# 检查是否死亡(如果 decrease_max_lifespan 导致当前年龄超过上限,会在下一次 age update 或者 death check 中发现,
# 但 decrease_max_lifespan 可能已经触发了 set_dead 如果它内部有逻辑,
# 不过根据 CultivationProgress.breakthrough 的逻辑,只是 decrease真正死亡判定在 simulator 循环里)
# 我们手动检查一下给个提示
is_dead = self.avatar.age.age >= self.avatar.age.max_lifespan
result_text = t("retreat_fail", reduce_years=reduce_years)
if is_dead:
result_text += t("retreat_death_append")
core_text = t("{avatar} failed retreat and lost {years} years of lifespan.", avatar=self.avatar.name, years=reduce_years)
prompt = t("retreat_story_prompt_fail")
story = await StoryTeller.tell_story(core_text, result_text, self.avatar, prompt=prompt)
events.append(Event(self.world.month_stamp, core_text, related_avatars=[self.avatar.id], is_major=True))
events.append(Event(self.world.month_stamp, story, related_avatars=[self.avatar.id], is_story=True))
return events
def can_start(self) -> tuple[bool, str]:
# 任何时候都可以闭关,只要没死
# 可以加个限制:寿元太少时不建议闭关?不,那是用户自己的选择(或者 AI 的愚蠢选择)
return True, ""
def start(self) -> Event:
# 记录开始
content = t("retreat_start", avatar=self.avatar.name)
return Event(self.world.month_stamp, content, related_avatars=[self.avatar.id], is_major=True)

View File

@@ -90,10 +90,7 @@ def _load_auxiliaries_data() -> tuple[Dict[int, Auxiliary], Dict[str, Auxiliary]
# 解析grade
grade_str = get_str(row, "grade", "练气")
try:
realm = next(r for r in Realm if r.value == grade_str)
except StopIteration:
realm = Realm.Qi_Refinement
realm = Realm.from_str(grade_str)
a = Auxiliary(
id=get_int(row, "item_id"),

View File

@@ -211,3 +211,18 @@ class ActionMixin:
return "\n".join(lines)
@property
def can_join_gathering(self: "Avatar") -> bool:
"""是否可以参加聚会"""
if self.current_action and self.current_action.action:
return getattr(self.current_action.action, 'ALLOW_GATHERING', True)
return True # 空闲状态默认可以
@property
def can_trigger_world_event(self: "Avatar") -> bool:
"""是否可以触发奇遇/霉运"""
if self.current_action and self.current_action.action:
return getattr(self.current_action.action, 'ALLOW_WORLD_EVENTS', True)
return True

View File

@@ -113,6 +113,8 @@ class Avatar(
custom_pic_id: Optional[int] = None
elixirs: List[ConsumedElixir] = field(default_factory=list)
# 临时效果列表: [{"source": str, "effects": dict, "start_month": int, "duration": int}]
temporary_effects: List[dict] = field(default_factory=list)
is_dead: bool = False
death_info: Optional[dict] = None
@@ -157,18 +159,30 @@ class Avatar(
1. 移除已完全过期的丹药
2. 如果有移除,触发属性重算
"""
if not self.elixirs:
return
original_count = len(self.elixirs)
# 过滤掉完全过期的
self.elixirs = [
e for e in self.elixirs
if not e.is_completely_expired(current_month)
]
need_recalc = False
# 如果数量减少说明有过期重算属性主要是寿命、MaxHP
if len(self.elixirs) < original_count:
# 处理丹药
if self.elixirs:
original_count = len(self.elixirs)
self.elixirs = [
e for e in self.elixirs
if not e.is_completely_expired(current_month)
]
if len(self.elixirs) < original_count:
need_recalc = True
# 处理临时效果
if self.temporary_effects:
original_temp_count = len(self.temporary_effects)
self.temporary_effects = [
eff for eff in self.temporary_effects
if current_month < (eff.get("start_month", 0) + eff.get("duration", 0))
]
if len(self.temporary_effects) < original_temp_count:
need_recalc = True
# 如果有过期,重算属性
if need_recalc:
self.recalc_effects()
def join_sect(self, sect: Sect, rank: "SectRank") -> None:

View File

@@ -6,6 +6,7 @@ from .consts import (
EXTRA_CULTIVATE_EXP,
CULTIVATE_DURATION_REDUCTION,
EXTRA_BREAKTHROUGH_SUCCESS_RATE,
EXTRA_RETREAT_SUCCESS_RATE,
EXTRA_DUAL_CULTIVATION_EXP,
EXTRA_HARVEST_MATERIALS,
EXTRA_HUNT_MATERIALS,

View File

@@ -91,6 +91,18 @@ EXTRA_BREAKTHROUGH_SUCCESS_RATE = "extra_breakthrough_success_rate"
- 大量: 0.3 (30%)
"""
EXTRA_RETREAT_SUCCESS_RATE = "extra_retreat_success_rate"
"""
额外闭关成功率
类型: float
结算: src/classes/action/retreat.py
说明: 闭关判定成功率的加成。
数值参考:
- 微量: 0.05 (5%)
- 中量: 0.1 (10%)
- 大量: 0.2 (20%)
"""
# --- 双修相关 ---
EXTRA_DUAL_CULTIVATION_EXP = "extra_dual_cultivation_exp"
"""
@@ -424,6 +436,7 @@ ALL_EFFECTS = [
"extra_cultivate_exp", # int - 额外修炼经验
"cultivate_duration_reduction", # float - 修炼时长缩减
"extra_breakthrough_success_rate", # float - 额外突破成功率
"extra_retreat_success_rate", # float - 额外闭关成功率
# 双修相关
"extra_dual_cultivation_exp", # int - 额外双修经验

View File

@@ -14,6 +14,7 @@ def get_effect_desc(effect_key: str) -> str:
"extra_weapon_proficiency_gain": "effect_extra_weapon_proficiency_gain",
"extra_dual_cultivation_exp": "effect_extra_dual_cultivation_exp",
"extra_breakthrough_success_rate": "effect_extra_breakthrough_success_rate",
"extra_retreat_success_rate": "effect_extra_retreat_success_rate",
"extra_fortune_probability": "effect_extra_fortune_probability",
"extra_misfortune_probability": "effect_extra_misfortune_probability",
"extra_harvest_materials": "effect_extra_harvest_materials",

View File

@@ -17,6 +17,14 @@ from src.classes.hp import HP_MAX_BY_REALM
class EffectsMixin:
"""效果计算相关方法"""
def get_active_temporary_effects(self: "Avatar") -> list[dict[str, Any]]:
"""获取当前生效的临时效果列表"""
current_month = int(self.world.month_stamp)
return [
eff for eff in getattr(self, "temporary_effects", [])
if current_month < eff.get("start_month", 0) + eff.get("duration", 0)
]
def _evaluate_values(self, effects: dict[str, Any]) -> dict[str, Any]:
"""
评估效果字典中的动态值(字符串表达式)。
@@ -138,6 +146,13 @@ class EffectsMixin:
label = t("Elixir [{name}]", name=consumed.elixir.name)
_collect(label, explicit_effects=active)
# 处理临时效果(如闭关获得的短期加成)
for temp_eff in self.get_active_temporary_effects():
# 来源显示,支持翻译
source_key = temp_eff.get("source", "Unknown")
label = t(source_key)
_collect(label, explicit_effects=temp_eff.get("effects", {}))
return breakdown
def recalc_effects(self: "Avatar") -> None:

View File

@@ -386,6 +386,10 @@ async def try_trigger_fortune(avatar: Avatar) -> list[Event]:
prob = base_prob + extra_prob
if prob <= 0.0:
return []
# 检查当前动作状态是否允许触发世界事件
if not avatar.can_trigger_world_event:
return []
if random.random() >= prob:
return []

View File

@@ -35,9 +35,13 @@ class Auction(Gathering):
def get_related_avatars(self, world: "World") -> List[int]:
"""
所有存活的 avatar 都参与
所有存活且允许参加聚会的 avatar 都参与
"""
return [avatar.id for avatar in world.avatar_manager.get_living_avatars()]
return [
avatar.id
for avatar in world.avatar_manager.get_living_avatars()
if avatar.can_join_gathering
]
def get_info(self, world: "World") -> str:
from src.i18n import t

View File

@@ -88,6 +88,10 @@ async def try_trigger_misfortune(avatar: Avatar) -> list[Event]:
prob = base_prob + extra_prob
if prob <= 0.0:
return []
# 检查当前动作状态是否允许触发世界事件
if not avatar.can_trigger_world_event:
return []
if random.random() >= prob:
return []

View File

@@ -2246,6 +2246,12 @@ msgstr "Breakthrough Success Rate"
msgid "effect_extra_retreat_success_rate"
msgstr "Retreat Success Rate"
msgid "effect_extra_fortune_probability"
msgstr "Fortune Probability"
@@ -3438,8 +3444,43 @@ msgstr "{winner} defeated {loser}"
# ============================================================================
msgid "retreat_action_name"
msgstr "Retreat"
msgid "retreat_desc"
msgstr "Retreat, to breakthrough self. Extremely risky, only for those with great perseverance."
msgid "retreat_requirements"
msgstr "No restrictions; cooldown required"
msgid "retreat_start"
msgstr "{avatar} begins retreat, breakthrough time unknown."
msgid "retreat_success"
msgstr "Retreat successfully completed! Cultivation improved, understanding of Heavenly Dao deepened."
msgid "retreat_fail"
msgstr "Retreat failed! Vitality greatly damaged, lost {years} years of lifespan."
msgid "retreat_death_append"
msgstr " And exhausted the last trace of vitality during retreat, passed away."
msgid "retreat_story_prompt_success"
msgstr "This is about a successful retreat. Describe how the cultivator overcame loneliness, comprehended the Heavenly Dao, and finally broke through the state of mind during the retreat. No combat description needed."
msgid "retreat_story_prompt_fail"
msgstr "This is about a failed retreat. Describe how the cultivator was troubled by inner demons, self-doubt, and finally suffered backlash during the retreat. No combat description needed."
msgid "{avatar} finished retreat successfully."
msgstr "{avatar} finished retreat successfully."
msgid "{avatar} failed retreat and lost {years} years of lifespan."
msgstr "{avatar} failed retreat and lost {years} years of lifespan."
msgid "Retreat Bonus"
msgstr "Retreat Insight"
# ============================================================================
# Frontend UI Translations
# ============================================================================

View File

@@ -1122,6 +1122,9 @@ msgstr "双修经验"
msgid "effect_extra_breakthrough_success_rate"
msgstr "突破成功率"
msgid "effect_extra_retreat_success_rate"
msgstr "闭关成功率"
msgid "effect_extra_fortune_probability"
msgstr "奇遇概率"
@@ -1718,6 +1721,42 @@ msgstr "{avatar} 修为增长 {exp} 点"
msgid "{winner} defeated {loser}"
msgstr "{winner} 战胜了 {loser}"
msgid "retreat_action_name"
msgstr "闭关"
msgid "retreat_desc"
msgstr "闭关,尝试突破自我。风险极大,非大毅力者不可为。"
msgid "retreat_requirements"
msgstr "无限制;需要冷却"
msgid "retreat_start"
msgstr "{avatar} 开始闭关,破关时间不详。"
msgid "retreat_success"
msgstr "闭关成功!修为精进,对天道的感悟加深了。"
msgid "retreat_fail"
msgstr "闭关失败!元气大伤,折损了 {years} 年阳寿。"
msgid "retreat_death_append"
msgstr "并且在闭关中耗尽了最后一丝生机,坐化了。"
msgid "retreat_story_prompt_success"
msgstr "这是关于一次成功的闭关。描写修士在闭关期间如何战胜孤独、感悟天道,最终突破心境的过程。不需要描写战斗。"
msgid "retreat_story_prompt_fail"
msgstr "这是关于一次失败的闭关。描写修士在闭关期间如何被心魔困扰、自我怀疑,最终导致反噬的过程。不需要描写战斗。"
msgid "{avatar} finished retreat successfully."
msgstr "{avatar} 闭关成功,修为大增。"
msgid "{avatar} failed retreat and lost {years} years of lifespan."
msgstr "{avatar} 闭关失败,折损了 {years} 年阳寿。"
msgid "Retreat Bonus"
msgstr "闭关感悟"
# ============================================================================
# Frontend UI Translations
# ============================================================================

View File

@@ -223,6 +223,9 @@ class AvatarLoadMixin:
consume_time = elixir_data["time"]
avatar.elixirs.append(ConsumedElixir(elixir_obj, consume_time))
# 恢复临时效果
avatar.temporary_effects = data.get("temporary_effects", [])
# 加载完成后重新计算effects确保数值正确
avatar.recalc_effects()

View File

@@ -116,5 +116,6 @@ class AvatarSaveMixin:
}
for consumed in self.elixirs
],
"temporary_effects": self.temporary_effects,
}