feat(engine): 实现阵营技能树系统

- 新增 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 <noreply@anthropic.com>
This commit is contained in:
empty
2025-12-30 13:25:20 +08:00
parent 62b4428cf1
commit 3f75eb26c4
5 changed files with 215 additions and 0 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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):

View File

@@ -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(),
)

View File

@@ -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