From 3f75eb26c4bd293c0e8e6c85c8754210dc6e3818 Mon Sep 17 00:00:00 2001 From: empty Date: Tue, 30 Dec 2025 13:25:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(engine):=20=E5=AE=9E=E7=8E=B0=E9=98=B5?= =?UTF-8?q?=E8=90=A5=E6=8A=80=E8=83=BD=E6=A0=91=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 FactionSkill、FactionSkillTree、FactionSkills 数据模型 - 创建 faction_skills.py 模块处理技能解锁和效果应用 - 技能解锁条件:power >= cost 且前置技能已解锁 - 技能效果:increase_positive_emotion、reduce_conflict、increase_fear、suppress_others - 添加 4 个技能树测试用例,全部 30 个测试通过 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- engine-python/app/engine.py | 7 ++ engine-python/app/faction_skills.py | 126 ++++++++++++++++++++++++++++ engine-python/app/models.py | 20 +++++ engine-python/app/state.py | 2 + engine-python/tests/test_step.py | 60 +++++++++++++ 5 files changed, 215 insertions(+) create mode 100644 engine-python/app/faction_skills.py diff --git a/engine-python/app/engine.py b/engine-python/app/engine.py index 5e1a283..e596a82 100644 --- a/engine-python/app/engine.py +++ b/engine-python/app/engine.py @@ -11,6 +11,7 @@ from .factions import ( update_factions, apply_faction_influence, check_and_trigger_faction_event ) from .voting import process_votes, apply_votes_to_factions +from .faction_skills import check_and_unlock_skills, apply_skill_effects MAX_EVENTS = 20 MAX_MEMORY_PER_AGENT = 3 @@ -106,6 +107,12 @@ def process_events(state: WorldState, events: List[Event]) -> WorldState: # 派系影响世界情绪 apply_faction_influence(state) + # 检查并解锁技能 + check_and_unlock_skills(state) + + # 应用已解锁技能效果 + apply_skill_effects(state) + return state diff --git a/engine-python/app/faction_skills.py b/engine-python/app/faction_skills.py new file mode 100644 index 0000000..9145afc --- /dev/null +++ b/engine-python/app/faction_skills.py @@ -0,0 +1,126 @@ +"""阵营技能树系统 - 允许阵营通过累积能量解锁技能""" +from typing import List, Optional +from .models import ( + WorldState, FactionSkill, FactionSkillTree, FactionSkills +) + + +def get_default_faction_skills() -> FactionSkills: + """返回默认技能树配置""" + return FactionSkills( + optimists=FactionSkillTree( + skills={ + "festival_boost": FactionSkill( + unlocked=False, + cost=10, + effect="increase_positive_emotion", + requires=[] + ), + "unity": FactionSkill( + unlocked=False, + cost=20, + effect="reduce_conflict", + requires=["festival_boost"] + ) + } + ), + fearful=FactionSkillTree( + skills={ + "panic_spread": FactionSkill( + unlocked=False, + cost=10, + effect="increase_fear", + requires=[] + ), + "control": FactionSkill( + unlocked=False, + cost=20, + effect="suppress_others", + requires=["panic_spread"] + ) + } + ) + ) + + +def _check_requires_met(skill_tree: FactionSkillTree, requires: List[str]) -> bool: + """检查前置技能是否已解锁""" + for req in requires: + if req not in skill_tree.skills: + return False + if not skill_tree.skills[req].unlocked: + return False + return True + + +def check_and_unlock_skills(state: WorldState) -> List[str]: + """检查并解锁技能 + + 规则: + 1. faction.power >= skill.cost + 2. 技能尚未解锁 + 3. 所有 requires 已解锁 + + 返回:本次解锁的技能列表 + """ + unlocked = [] + + # 检查乐观派技能 + for skill_id, skill in state.faction_skills.optimists.skills.items(): + if skill.unlocked: + continue + if state.factions.optimists.power < skill.cost: + continue + if not _check_requires_met(state.faction_skills.optimists, skill.requires): + continue + + # 解锁技能 + skill.unlocked = True + state.factions.optimists.power -= skill.cost + unlocked.append(f"optimists:{skill_id}") + + # 检查恐惧派技能 + for skill_id, skill in state.faction_skills.fearful.skills.items(): + if skill.unlocked: + continue + if state.factions.fearful.power < skill.cost: + continue + if not _check_requires_met(state.faction_skills.fearful, skill.requires): + continue + + # 解锁技能 + skill.unlocked = True + state.factions.fearful.power -= skill.cost + unlocked.append(f"fearful:{skill_id}") + + return unlocked + + +def apply_skill_effects(state: WorldState) -> None: + """应用已解锁技能的持续效果 + + 效果说明: + - increase_positive_emotion: 每 tick 提升 town_mood +1 + - reduce_conflict: 降低恐惧派能量增长(每 tick -1) + - increase_fear: 每 tick 降低 town_mood -1 + - suppress_others: 降低乐观派能量增长(每 tick -1) + """ + # 乐观派技能效果 + for skill_id, skill in state.faction_skills.optimists.skills.items(): + if not skill.unlocked: + continue + + if skill.effect == "increase_positive_emotion": + state.town_mood = min(10, state.town_mood + 1) + elif skill.effect == "reduce_conflict": + state.factions.fearful.power = max(0, state.factions.fearful.power - 1) + + # 恐惧派技能效果 + for skill_id, skill in state.faction_skills.fearful.skills.items(): + if not skill.unlocked: + continue + + if skill.effect == "increase_fear": + state.town_mood = max(-10, state.town_mood - 1) + elif skill.effect == "suppress_others": + state.factions.optimists.power = max(0, state.factions.optimists.power - 1) diff --git a/engine-python/app/models.py b/engine-python/app/models.py index 4514ceb..0f20917 100644 --- a/engine-python/app/models.py +++ b/engine-python/app/models.py @@ -66,6 +66,25 @@ class Votes(BaseModel): voted_users: List[str] = Field(default_factory=list) +class FactionSkill(BaseModel): + """单个技能""" + unlocked: bool = False + cost: int = 10 + effect: str = "" + requires: List[str] = Field(default_factory=list) + + +class FactionSkillTree(BaseModel): + """单个阵营的技能树""" + skills: Dict[str, FactionSkill] = Field(default_factory=dict) + + +class FactionSkills(BaseModel): + """所有阵营的技能树""" + optimists: FactionSkillTree = Field(default_factory=FactionSkillTree) + fearful: FactionSkillTree = Field(default_factory=FactionSkillTree) + + class FactionEventResult(BaseModel): """阵营事件触发结果""" type: Optional[str] = None # "festival" | "panic" | None @@ -113,6 +132,7 @@ class WorldState(BaseModel): factions: Factions = Field(default_factory=Factions) story_arcs: Dict[str, StoryArc] = Field(default_factory=dict) votes: Votes = Field(default_factory=Votes) + faction_skills: FactionSkills = Field(default_factory=FactionSkills) class Event(BaseModel): diff --git a/engine-python/app/state.py b/engine-python/app/state.py index 4861be6..74075c7 100644 --- a/engine-python/app/state.py +++ b/engine-python/app/state.py @@ -2,6 +2,7 @@ import json from pathlib import Path from .models import WorldState, AgentState, GlobalMeter, Stance, Factions, FactionData from .story_arcs import get_default_story_arcs +from .faction_skills import get_default_faction_skills STATE_FILE = Path(__file__).parent.parent / "state.json" @@ -35,6 +36,7 @@ def get_default_state() -> WorldState: fearful=FactionData(power=0, threshold=10, skill="panic", members=[]) ), story_arcs=get_default_story_arcs(), + faction_skills=get_default_faction_skills(), ) diff --git a/engine-python/tests/test_step.py b/engine-python/tests/test_step.py index e7db159..5f15ed9 100644 --- a/engine-python/tests/test_step.py +++ b/engine-python/tests/test_step.py @@ -481,3 +481,63 @@ def test_votes_trigger_faction_event(client): # 要么触发了事件,要么能量在累积 assert triggered or factions["optimists"]["power"] > 0 + + +# ==================== 技能树系统测试 ==================== + +def test_faction_skills_exists_in_world_state(client): + """测试 world_state 包含 faction_skills 字段""" + resp = client.post("/step", json={"events": []}) + data = resp.json() + + assert "faction_skills" in data["world_state"] + skills = data["world_state"]["faction_skills"] + assert "optimists" in skills + assert "fearful" in skills + + +def test_faction_skills_has_required_fields(client): + """测试技能包含必要字段""" + resp = client.post("/step", json={"events": []}) + data = resp.json() + + skills = data["world_state"]["faction_skills"] + opt_skills = skills["optimists"]["skills"] + + assert "festival_boost" in opt_skills + skill = opt_skills["festival_boost"] + assert "unlocked" in skill + assert "cost" in skill + assert "effect" in skill + assert "requires" in skill + + +def test_skill_unlock_when_power_sufficient(client): + """测试能量足够时解锁技能""" + # 多轮投票累积能量到 10+ + for i in range(6): + events = [ + {"type": "vote", "faction": "optimists", "text": "", "user": f"u{i}_a", "ts": i}, + {"type": "vote", "faction": "optimists", "text": "", "user": f"u{i}_b", "ts": i+0.1}, + ] + client.post("/step", json={"events": events}) + + # 再执行一轮让投票生效 + resp = client.post("/step", json={"events": []}) + data = resp.json() + + skills = data["world_state"]["faction_skills"]["optimists"]["skills"] + # festival_boost cost=10,应该已解锁 + assert skills["festival_boost"]["unlocked"] is True + + +def test_skill_requires_prerequisite(client): + """测试技能需要前置技能""" + resp = client.post("/step", json={"events": []}) + data = resp.json() + + skills = data["world_state"]["faction_skills"]["optimists"]["skills"] + # unity 需要 festival_boost 作为前置 + assert skills["unity"]["requires"] == ["festival_boost"] + # 初始状态下 unity 未解锁 + assert skills["unity"]["unlocked"] is False