Compare commits
8 Commits
cec3e95a4b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2978c9b66 | ||
|
|
5ae63d9df9 | ||
|
|
4664796d0b | ||
|
|
3f75eb26c4 | ||
|
|
62b4428cf1 | ||
|
|
75e84f2ba3 | ||
|
|
87007d9b43 | ||
|
|
28976d9672 |
89
engine-python/app/action_points.py
Normal file
89
engine-python/app/action_points.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""行动点系统 - 管理用户行动点的消耗与恢复"""
|
||||||
|
from typing import List, Tuple, Dict
|
||||||
|
from .models import WorldState, Event, ActionFeedback
|
||||||
|
|
||||||
|
# 行动消耗表
|
||||||
|
ACTION_COST: Dict[str, int] = {
|
||||||
|
"vote": 1,
|
||||||
|
"trigger_skill": 2,
|
||||||
|
"influence": 2,
|
||||||
|
"comment": 0,
|
||||||
|
"support": 0,
|
||||||
|
"chaos": 0,
|
||||||
|
}
|
||||||
|
DEFAULT_COST = 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_action_cost(event_type: str) -> int:
|
||||||
|
"""获取行动消耗的点数"""
|
||||||
|
return ACTION_COST.get(event_type, DEFAULT_COST)
|
||||||
|
|
||||||
|
|
||||||
|
def check_action_points(state: WorldState, user: str, cost: int) -> bool:
|
||||||
|
"""检查用户是否有足够的行动点"""
|
||||||
|
if user not in state.agents:
|
||||||
|
return True # 非 agent 用户不受限制
|
||||||
|
return state.agents[user].action_points >= cost
|
||||||
|
|
||||||
|
|
||||||
|
def consume_action_points(
|
||||||
|
state: WorldState, user: str, cost: int
|
||||||
|
) -> None:
|
||||||
|
"""消耗行动点"""
|
||||||
|
if user not in state.agents:
|
||||||
|
return
|
||||||
|
agent = state.agents[user]
|
||||||
|
agent.action_points = max(0, agent.action_points - cost)
|
||||||
|
agent.last_action_tick = state.tick
|
||||||
|
|
||||||
|
|
||||||
|
def regenerate_action_points(state: WorldState) -> None:
|
||||||
|
"""每 tick 恢复行动点"""
|
||||||
|
for agent_id, agent in state.agents.items():
|
||||||
|
if state.tick - agent.last_action_tick >= 1:
|
||||||
|
if agent.action_points < agent.max_action_points:
|
||||||
|
agent.action_points = min(
|
||||||
|
agent.max_action_points,
|
||||||
|
agent.action_points + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_event_with_ap(
|
||||||
|
state: WorldState, event: Event
|
||||||
|
) -> Tuple[bool, ActionFeedback]:
|
||||||
|
"""处理单个事件的行动点检查
|
||||||
|
|
||||||
|
返回: (是否允许执行, 反馈信息)
|
||||||
|
"""
|
||||||
|
user = event.user
|
||||||
|
cost = get_action_cost(event.type)
|
||||||
|
|
||||||
|
# 0 消耗的行动不需要检查
|
||||||
|
if cost == 0:
|
||||||
|
return True, ActionFeedback(
|
||||||
|
success=True,
|
||||||
|
reason="action applied",
|
||||||
|
remaining_ap=state.agents[user].action_points if user in state.agents else 0,
|
||||||
|
user=user
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查行动点
|
||||||
|
if not check_action_points(state, user, cost):
|
||||||
|
remaining = state.agents[user].action_points if user in state.agents else 0
|
||||||
|
return False, ActionFeedback(
|
||||||
|
success=False,
|
||||||
|
reason=f"insufficient action points (need {cost}, have {remaining})",
|
||||||
|
remaining_ap=remaining,
|
||||||
|
user=user
|
||||||
|
)
|
||||||
|
|
||||||
|
# 消耗行动点
|
||||||
|
consume_action_points(state, user, cost)
|
||||||
|
remaining = state.agents[user].action_points if user in state.agents else 0
|
||||||
|
|
||||||
|
return True, ActionFeedback(
|
||||||
|
success=True,
|
||||||
|
reason="action applied",
|
||||||
|
remaining_ap=remaining,
|
||||||
|
user=user
|
||||||
|
)
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
from .models import (
|
from .models import (
|
||||||
WorldState, AgentState, Event, Action, Emotion, Weather,
|
WorldState, AgentState, Event, Action, Emotion, Weather,
|
||||||
GlobalEventResult, GlobalEventInfo, FactionEventResult
|
GlobalEventResult, GlobalEventInfo, FactionEventResult, ActionFeedback
|
||||||
)
|
)
|
||||||
from .global_events import GLOBAL_EVENT_POOL, GlobalEvent
|
from .global_events import GLOBAL_EVENT_POOL, GlobalEvent
|
||||||
from .opinions import generate_opinions
|
from .opinions import generate_opinions
|
||||||
@@ -10,6 +10,11 @@ from .social_influence import apply_social_influence
|
|||||||
from .factions import (
|
from .factions import (
|
||||||
update_factions, apply_faction_influence, check_and_trigger_faction_event
|
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
|
||||||
|
from .action_points import (
|
||||||
|
regenerate_action_points, process_event_with_ap, get_action_cost
|
||||||
|
)
|
||||||
|
|
||||||
MAX_EVENTS = 20
|
MAX_EVENTS = 20
|
||||||
MAX_MEMORY_PER_AGENT = 3
|
MAX_MEMORY_PER_AGENT = 3
|
||||||
@@ -38,11 +43,19 @@ def update_world_effects(state: WorldState) -> None:
|
|||||||
state.world_effects = active_effects
|
state.world_effects = active_effects
|
||||||
|
|
||||||
|
|
||||||
def process_events(state: WorldState, events: List[Event]) -> WorldState:
|
def process_events(
|
||||||
"""处理事件并更新世界状态"""
|
state: WorldState, events: List[Event]
|
||||||
|
) -> Tuple[WorldState, List[ActionFeedback]]:
|
||||||
|
"""处理事件并更新世界状态,返回行动反馈列表"""
|
||||||
# tick 递增
|
# tick 递增
|
||||||
state.tick += 1
|
state.tick += 1
|
||||||
|
|
||||||
|
# ★ 行动点恢复
|
||||||
|
regenerate_action_points(state)
|
||||||
|
|
||||||
|
# ★ 在 tick 开始时:将上一轮投票累加到阵营能量
|
||||||
|
apply_votes_to_factions(state)
|
||||||
|
|
||||||
# cooldown 递减
|
# cooldown 递减
|
||||||
if state.global_meter.cooldown > 0:
|
if state.global_meter.cooldown > 0:
|
||||||
state.global_meter.cooldown -= 1
|
state.global_meter.cooldown -= 1
|
||||||
@@ -50,7 +63,20 @@ def process_events(state: WorldState, events: List[Event]) -> WorldState:
|
|||||||
# 更新世界效果(持续影响)
|
# 更新世界效果(持续影响)
|
||||||
update_world_effects(state)
|
update_world_effects(state)
|
||||||
|
|
||||||
|
# ★ 处理投票事件(记录到 votes,下一 tick 生效)
|
||||||
|
process_votes(state, events)
|
||||||
|
|
||||||
|
# 收集行动反馈
|
||||||
|
action_feedbacks: List[ActionFeedback] = []
|
||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
|
# ★ 行动点检查
|
||||||
|
allowed, feedback = process_event_with_ap(state, event)
|
||||||
|
action_feedbacks.append(feedback)
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
continue # 行动点不足,跳过此事件
|
||||||
|
|
||||||
text = event.text
|
text = event.text
|
||||||
|
|
||||||
# 累计能量
|
# 累计能量
|
||||||
@@ -99,7 +125,13 @@ def process_events(state: WorldState, events: List[Event]) -> WorldState:
|
|||||||
# 派系影响世界情绪
|
# 派系影响世界情绪
|
||||||
apply_faction_influence(state)
|
apply_faction_influence(state)
|
||||||
|
|
||||||
return state
|
# 检查并解锁技能
|
||||||
|
check_and_unlock_skills(state)
|
||||||
|
|
||||||
|
# 应用已解锁技能效果
|
||||||
|
apply_skill_effects(state)
|
||||||
|
|
||||||
|
return state, action_feedbacks
|
||||||
|
|
||||||
|
|
||||||
def check_and_trigger_global_event(
|
def check_and_trigger_global_event(
|
||||||
|
|||||||
126
engine-python/app/faction_skills.py
Normal file
126
engine-python/app/faction_skills.py
Normal 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)
|
||||||
@@ -10,7 +10,7 @@ OPTIMIST_THRESHOLD = 0.6
|
|||||||
FEARFUL_THRESHOLD = 0.6
|
FEARFUL_THRESHOLD = 0.6
|
||||||
|
|
||||||
# 阵营技能触发阈值
|
# 阵营技能触发阈值
|
||||||
FACTION_POWER_THRESHOLD = 5
|
FACTION_POWER_THRESHOLD = 10
|
||||||
|
|
||||||
# 阵营事件对情绪的影响
|
# 阵营事件对情绪的影响
|
||||||
FACTION_EVENT_EFFECTS = {
|
FACTION_EVENT_EFFECTS = {
|
||||||
@@ -39,17 +39,18 @@ def update_factions(state: WorldState) -> None:
|
|||||||
"""统计各派系并更新 world_state
|
"""统计各派系并更新 world_state
|
||||||
|
|
||||||
流程:
|
流程:
|
||||||
1. 清空 factions.members
|
1. 清空 factions.members(但保留 power)
|
||||||
2. 遍历所有 agents,根据 emotion 分类
|
2. 遍历所有 agents,根据 emotion 分类
|
||||||
3. 更新 agent.faction 字段
|
3. 更新 agent.faction 字段
|
||||||
4. 将 agent 加入对应 faction.members
|
4. 将 agent 加入对应 faction.members
|
||||||
5. 每个 agent 为 faction 增加 power += 1
|
5. 每个 agent 为 faction 增加 power += 1(累积)
|
||||||
"""
|
"""
|
||||||
# 重置 factions
|
# 保留现有 power,只清空 members
|
||||||
state.factions = Factions(
|
current_optimist_power = state.factions.optimists.power
|
||||||
optimists=FactionData(power=0, members=[]),
|
current_fearful_power = state.factions.fearful.power
|
||||||
fearful=FactionData(power=0, members=[])
|
|
||||||
)
|
state.factions.optimists.members = []
|
||||||
|
state.factions.fearful.members = []
|
||||||
|
|
||||||
for agent_id, agent in state.agents.items():
|
for agent_id, agent in state.agents.items():
|
||||||
faction = classify_faction(agent)
|
faction = classify_faction(agent)
|
||||||
@@ -57,11 +58,12 @@ def update_factions(state: WorldState) -> None:
|
|||||||
|
|
||||||
if faction == "optimists":
|
if faction == "optimists":
|
||||||
state.factions.optimists.members.append(agent_id)
|
state.factions.optimists.members.append(agent_id)
|
||||||
state.factions.optimists.power += 1
|
state.factions.optimists.power = current_optimist_power + 1
|
||||||
|
current_optimist_power = state.factions.optimists.power
|
||||||
elif faction == "fearful":
|
elif faction == "fearful":
|
||||||
state.factions.fearful.members.append(agent_id)
|
state.factions.fearful.members.append(agent_id)
|
||||||
state.factions.fearful.power += 1
|
state.factions.fearful.power = current_fearful_power + 1
|
||||||
# neutral 不加入任何派系
|
current_fearful_power = state.factions.fearful.power
|
||||||
|
|
||||||
|
|
||||||
def apply_faction_influence(state: WorldState) -> None:
|
def apply_faction_influence(state: WorldState) -> None:
|
||||||
@@ -79,7 +81,7 @@ def check_and_trigger_faction_event(state: WorldState) -> FactionEventResult:
|
|||||||
"""检查并触发阵营事件
|
"""检查并触发阵营事件
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
- 当 faction.power >= 5 时,触发对应世界事件
|
- 当 faction.power >= faction.threshold 时,触发对应技能
|
||||||
- optimists → festival(提升情绪)
|
- optimists → festival(提升情绪)
|
||||||
- fearful → panic(降低情绪)
|
- fearful → panic(降低情绪)
|
||||||
- 触发后该 faction.power 归零
|
- 触发后该 faction.power 归零
|
||||||
@@ -87,13 +89,13 @@ def check_and_trigger_faction_event(state: WorldState) -> FactionEventResult:
|
|||||||
result = FactionEventResult(type=None, source_faction=None)
|
result = FactionEventResult(type=None, source_faction=None)
|
||||||
|
|
||||||
# 检查乐观派
|
# 检查乐观派
|
||||||
if state.factions.optimists.power >= FACTION_POWER_THRESHOLD:
|
if state.factions.optimists.power >= state.factions.optimists.threshold:
|
||||||
result = FactionEventResult(type="festival", source_faction="optimists")
|
result = FactionEventResult(type="festival", source_faction="optimists")
|
||||||
_apply_faction_event(state, "festival")
|
_apply_faction_event(state, "festival")
|
||||||
state.factions.optimists.power = 0
|
state.factions.optimists.power = 0
|
||||||
|
|
||||||
# 检查恐惧派
|
# 检查恐惧派
|
||||||
elif state.factions.fearful.power >= FACTION_POWER_THRESHOLD:
|
elif state.factions.fearful.power >= state.factions.fearful.threshold:
|
||||||
result = FactionEventResult(type="panic", source_faction="fearful")
|
result = FactionEventResult(type="panic", source_faction="fearful")
|
||||||
_apply_faction_event(state, "panic")
|
_apply_faction_event(state, "panic")
|
||||||
state.factions.fearful.power = 0
|
state.factions.fearful.power = 0
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ def step(request: StepRequest) -> StepResponse:
|
|||||||
# 加载当前状态
|
# 加载当前状态
|
||||||
state = load_state()
|
state = load_state()
|
||||||
|
|
||||||
# 处理事件并更新状态
|
# 处理事件并更新状态,获取行动反馈
|
||||||
state = process_events(state, request.events)
|
state, action_feedbacks = process_events(state, request.events)
|
||||||
|
|
||||||
# 检查并触发世界级事件
|
# 检查并触发世界级事件
|
||||||
state, global_event_result = check_and_trigger_global_event(state)
|
state, global_event_result = check_and_trigger_global_event(state)
|
||||||
@@ -40,7 +40,8 @@ def step(request: StepRequest) -> StepResponse:
|
|||||||
actions=actions,
|
actions=actions,
|
||||||
global_event=global_event_result,
|
global_event=global_event_result,
|
||||||
triggered_faction_event=faction_event_result,
|
triggered_faction_event=faction_event_result,
|
||||||
story_event=story_event_result
|
story_event=story_event_result,
|
||||||
|
action_feedbacks=action_feedbacks
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ class Stance(BaseModel):
|
|||||||
class FactionData(BaseModel):
|
class FactionData(BaseModel):
|
||||||
"""单个派系的数据"""
|
"""单个派系的数据"""
|
||||||
power: int = 0
|
power: int = 0
|
||||||
|
threshold: int = 10
|
||||||
|
skill: str = ""
|
||||||
members: List[str] = Field(default_factory=list)
|
members: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +58,33 @@ class Factions(BaseModel):
|
|||||||
fearful: FactionData = Field(default_factory=FactionData)
|
fearful: FactionData = Field(default_factory=FactionData)
|
||||||
|
|
||||||
|
|
||||||
|
class Votes(BaseModel):
|
||||||
|
"""投票统计"""
|
||||||
|
optimists: int = 0
|
||||||
|
fearful: int = 0
|
||||||
|
# 记录本 tick 已投票的用户(用于去重)
|
||||||
|
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):
|
class FactionEventResult(BaseModel):
|
||||||
"""阵营事件触发结果"""
|
"""阵营事件触发结果"""
|
||||||
type: Optional[str] = None # "festival" | "panic" | None
|
type: Optional[str] = None # "festival" | "panic" | None
|
||||||
@@ -79,6 +108,14 @@ class StoryEventResult(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ActionFeedback(BaseModel):
|
||||||
|
"""行动反馈"""
|
||||||
|
success: bool = True
|
||||||
|
reason: str = ""
|
||||||
|
remaining_ap: int = 0
|
||||||
|
user: str = ""
|
||||||
|
|
||||||
|
|
||||||
class AgentState(BaseModel):
|
class AgentState(BaseModel):
|
||||||
emotion: Emotion = Emotion.CALM
|
emotion: Emotion = Emotion.CALM
|
||||||
goal: str = ""
|
goal: str = ""
|
||||||
@@ -90,6 +127,10 @@ class AgentState(BaseModel):
|
|||||||
stance: Stance = Field(default_factory=Stance)
|
stance: Stance = Field(default_factory=Stance)
|
||||||
# 所属阵营
|
# 所属阵营
|
||||||
faction: str = "neutral" # "optimists" | "fearful" | "neutral"
|
faction: str = "neutral" # "optimists" | "fearful" | "neutral"
|
||||||
|
# 行动点系统
|
||||||
|
action_points: int = Field(default=3, ge=0)
|
||||||
|
max_action_points: int = Field(default=3, ge=1)
|
||||||
|
last_action_tick: int = 0
|
||||||
|
|
||||||
|
|
||||||
class WorldState(BaseModel):
|
class WorldState(BaseModel):
|
||||||
@@ -102,6 +143,8 @@ class WorldState(BaseModel):
|
|||||||
world_effects: List[WorldEffect] = Field(default_factory=list)
|
world_effects: List[WorldEffect] = Field(default_factory=list)
|
||||||
factions: Factions = Field(default_factory=Factions)
|
factions: Factions = Field(default_factory=Factions)
|
||||||
story_arcs: Dict[str, StoryArc] = Field(default_factory=dict)
|
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):
|
class Event(BaseModel):
|
||||||
@@ -109,6 +152,7 @@ class Event(BaseModel):
|
|||||||
text: str
|
text: str
|
||||||
user: str
|
user: str
|
||||||
ts: float
|
ts: float
|
||||||
|
faction: Optional[str] = None # 用于投票事件: "optimists" | "fearful"
|
||||||
|
|
||||||
|
|
||||||
class StepRequest(BaseModel):
|
class StepRequest(BaseModel):
|
||||||
@@ -139,3 +183,4 @@ class StepResponse(BaseModel):
|
|||||||
global_event: GlobalEventResult = Field(default_factory=GlobalEventResult)
|
global_event: GlobalEventResult = Field(default_factory=GlobalEventResult)
|
||||||
triggered_faction_event: FactionEventResult = Field(default_factory=FactionEventResult)
|
triggered_faction_event: FactionEventResult = Field(default_factory=FactionEventResult)
|
||||||
story_event: StoryEventResult = Field(default_factory=StoryEventResult)
|
story_event: StoryEventResult = Field(default_factory=StoryEventResult)
|
||||||
|
action_feedbacks: List[ActionFeedback] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from .models import WorldState, AgentState, GlobalMeter, Stance, Factions, FactionData
|
from .models import WorldState, AgentState, GlobalMeter, Stance, Factions, FactionData
|
||||||
from .story_arcs import get_default_story_arcs
|
from .story_arcs import get_default_story_arcs
|
||||||
|
from .faction_skills import get_default_faction_skills
|
||||||
|
|
||||||
STATE_FILE = Path(__file__).parent.parent / "state.json"
|
STATE_FILE = Path(__file__).parent.parent / "state.json"
|
||||||
|
|
||||||
@@ -19,6 +20,9 @@ def get_default_state() -> WorldState:
|
|||||||
memory=[],
|
memory=[],
|
||||||
stance=Stance(optimism=0.6, fear=0.4),
|
stance=Stance(optimism=0.6, fear=0.4),
|
||||||
faction="neutral",
|
faction="neutral",
|
||||||
|
action_points=3,
|
||||||
|
max_action_points=3,
|
||||||
|
last_action_tick=0,
|
||||||
),
|
),
|
||||||
"bob": AgentState(
|
"bob": AgentState(
|
||||||
emotion="calm",
|
emotion="calm",
|
||||||
@@ -26,15 +30,19 @@ def get_default_state() -> WorldState:
|
|||||||
memory=[],
|
memory=[],
|
||||||
stance=Stance(optimism=0.4, fear=0.6),
|
stance=Stance(optimism=0.4, fear=0.6),
|
||||||
faction="neutral",
|
faction="neutral",
|
||||||
|
action_points=3,
|
||||||
|
max_action_points=3,
|
||||||
|
last_action_tick=0,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
events=[],
|
events=[],
|
||||||
global_meter=GlobalMeter(value=0, threshold=100, cooldown=0),
|
global_meter=GlobalMeter(value=0, threshold=100, cooldown=0),
|
||||||
factions=Factions(
|
factions=Factions(
|
||||||
optimists=FactionData(power=0, members=[]),
|
optimists=FactionData(power=0, threshold=10, skill="festival", members=[]),
|
||||||
fearful=FactionData(power=0, members=[])
|
fearful=FactionData(power=0, threshold=10, skill="panic", members=[])
|
||||||
),
|
),
|
||||||
story_arcs=get_default_story_arcs(),
|
story_arcs=get_default_story_arcs(),
|
||||||
|
faction_skills=get_default_faction_skills(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
46
engine-python/app/voting.py
Normal file
46
engine-python/app/voting.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""阵营投票系统 - 允许用户通过投票影响阵营能量"""
|
||||||
|
from typing import List
|
||||||
|
from .models import WorldState, Event, Votes
|
||||||
|
|
||||||
|
|
||||||
|
def process_votes(state: WorldState, events: List[Event]) -> None:
|
||||||
|
"""处理投票事件
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 每个用户在一个 tick 内只能投 1 次票(按 user 去重)
|
||||||
|
2. 每票增加对应 faction 的 votes += 1
|
||||||
|
"""
|
||||||
|
for event in events:
|
||||||
|
if event.type != "vote":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 验证 faction 有效性
|
||||||
|
if event.faction not in ("optimists", "fearful"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查用户是否已投票(去重)
|
||||||
|
if event.user in state.votes.voted_users:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 记录投票
|
||||||
|
state.votes.voted_users.append(event.user)
|
||||||
|
|
||||||
|
if event.faction == "optimists":
|
||||||
|
state.votes.optimists += 1
|
||||||
|
elif event.faction == "fearful":
|
||||||
|
state.votes.fearful += 1
|
||||||
|
|
||||||
|
|
||||||
|
def apply_votes_to_factions(state: WorldState) -> None:
|
||||||
|
"""将投票累加到阵营能量,然后清空投票
|
||||||
|
|
||||||
|
在每个 tick 开始时调用:
|
||||||
|
1. 将 votes 累加进 factions.power
|
||||||
|
2. 清空 votes(包括 voted_users)
|
||||||
|
"""
|
||||||
|
# 累加投票到阵营能量
|
||||||
|
state.factions.optimists.power += state.votes.optimists
|
||||||
|
state.factions.fearful.power += state.votes.fearful
|
||||||
|
|
||||||
|
# 清空投票
|
||||||
|
state.votes = Votes()
|
||||||
@@ -381,3 +381,296 @@ def test_story_event_in_response(client):
|
|||||||
assert "story_event" in data
|
assert "story_event" in data
|
||||||
assert "triggered" in data["story_event"]
|
assert "triggered" in data["story_event"]
|
||||||
assert "arc_id" in data["story_event"]
|
assert "arc_id" in data["story_event"]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 投票系统测试 ====================
|
||||||
|
|
||||||
|
def test_votes_exists_in_world_state(client):
|
||||||
|
"""测试 world_state 包含 votes 字段"""
|
||||||
|
resp = client.post("/step", json={"events": []})
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
assert "votes" in data["world_state"]
|
||||||
|
votes = data["world_state"]["votes"]
|
||||||
|
assert "optimists" in votes
|
||||||
|
assert "fearful" in votes
|
||||||
|
assert "voted_users" in votes
|
||||||
|
|
||||||
|
|
||||||
|
def test_vote_records_correctly(client):
|
||||||
|
"""测试投票正确记录"""
|
||||||
|
events = [
|
||||||
|
{"type": "vote", "faction": "optimists", "text": "", "user": "user1", "ts": 1},
|
||||||
|
{"type": "vote", "faction": "fearful", "text": "", "user": "user2", "ts": 2},
|
||||||
|
]
|
||||||
|
resp = client.post("/step", json={"events": events})
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
votes = data["world_state"]["votes"]
|
||||||
|
assert votes["optimists"] == 1
|
||||||
|
assert votes["fearful"] == 1
|
||||||
|
assert "user1" in votes["voted_users"]
|
||||||
|
assert "user2" in votes["voted_users"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_vote_user_deduplication(client):
|
||||||
|
"""测试同一用户在同一 tick 只能投一次票"""
|
||||||
|
events = [
|
||||||
|
{"type": "vote", "faction": "optimists", "text": "", "user": "user1", "ts": 1},
|
||||||
|
{"type": "vote", "faction": "optimists", "text": "", "user": "user1", "ts": 2},
|
||||||
|
{"type": "vote", "faction": "fearful", "text": "", "user": "user1", "ts": 3},
|
||||||
|
]
|
||||||
|
resp = client.post("/step", json={"events": events})
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
votes = data["world_state"]["votes"]
|
||||||
|
# 只计算第一次投票
|
||||||
|
assert votes["optimists"] == 1
|
||||||
|
assert votes["fearful"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_votes_apply_to_faction_power_next_tick(client):
|
||||||
|
"""测试投票在下一个 tick 累加到阵营能量"""
|
||||||
|
# 第一个 tick:投票
|
||||||
|
events = [
|
||||||
|
{"type": "vote", "faction": "optimists", "text": "", "user": "user1", "ts": 1},
|
||||||
|
{"type": "vote", "faction": "optimists", "text": "", "user": "user2", "ts": 2},
|
||||||
|
{"type": "vote", "faction": "fearful", "text": "", "user": "user3", "ts": 3},
|
||||||
|
]
|
||||||
|
resp1 = client.post("/step", json={"events": events})
|
||||||
|
data1 = resp1.json()
|
||||||
|
|
||||||
|
# 记录第一个 tick 后的阵营能量
|
||||||
|
power1_opt = data1["world_state"]["factions"]["optimists"]["power"]
|
||||||
|
power1_fear = data1["world_state"]["factions"]["fearful"]["power"]
|
||||||
|
|
||||||
|
# 第二个 tick:投票应该生效
|
||||||
|
resp2 = client.post("/step", json={"events": []})
|
||||||
|
data2 = resp2.json()
|
||||||
|
|
||||||
|
power2_opt = data2["world_state"]["factions"]["optimists"]["power"]
|
||||||
|
power2_fear = data2["world_state"]["factions"]["fearful"]["power"]
|
||||||
|
|
||||||
|
# 验证投票已累加(+2 optimists, +1 fearful)
|
||||||
|
assert power2_opt >= power1_opt + 2
|
||||||
|
assert power2_fear >= power1_fear + 1
|
||||||
|
|
||||||
|
# 验证投票已清空
|
||||||
|
assert data2["world_state"]["votes"]["optimists"] == 0
|
||||||
|
assert data2["world_state"]["votes"]["fearful"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_votes_trigger_faction_event(client):
|
||||||
|
"""测试投票累积触发阵营技能"""
|
||||||
|
# 多轮投票累积能量直到触发
|
||||||
|
for i in range(5):
|
||||||
|
events = [
|
||||||
|
{"type": "vote", "faction": "optimists", "text": "", "user": f"user_{i}_a", "ts": i},
|
||||||
|
{"type": "vote", "faction": "optimists", "text": "", "user": f"user_{i}_b", "ts": i + 0.1},
|
||||||
|
]
|
||||||
|
resp = client.post("/step", json={"events": events})
|
||||||
|
|
||||||
|
# 再投一轮让投票生效
|
||||||
|
resp = client.post("/step", json={"events": []})
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# 验证阵营能量已累积(可能已触发并重置)
|
||||||
|
# 如果触发了,power 会重置为 0
|
||||||
|
factions = data["world_state"]["factions"]
|
||||||
|
triggered = data["triggered_faction_event"]["type"] is not None
|
||||||
|
|
||||||
|
# 要么触发了事件,要么能量在累积
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 行动点系统测试 ====================
|
||||||
|
|
||||||
|
def test_action_points_exists_in_agent(client):
|
||||||
|
"""测试 agent 包含行动点字段"""
|
||||||
|
resp = client.post("/step", json={"events": []})
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
for agent_id, agent in data["world_state"]["agents"].items():
|
||||||
|
assert "action_points" in agent
|
||||||
|
assert "max_action_points" in agent
|
||||||
|
assert "last_action_tick" in agent
|
||||||
|
assert agent["action_points"] >= 0
|
||||||
|
assert agent["max_action_points"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_feedbacks_in_response(client):
|
||||||
|
"""测试响应包含 action_feedbacks 字段"""
|
||||||
|
resp = client.post("/step", json={"events": []})
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
assert "action_feedbacks" in data
|
||||||
|
assert isinstance(data["action_feedbacks"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vote_consumes_action_points(client):
|
||||||
|
"""测试投票消耗行动点"""
|
||||||
|
# alice 投票(消耗 1 点)
|
||||||
|
events = [
|
||||||
|
{"type": "vote", "faction": "optimists", "text": "", "user": "alice", "ts": 1}
|
||||||
|
]
|
||||||
|
resp = client.post("/step", json={"events": events})
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# 验证行动反馈
|
||||||
|
assert len(data["action_feedbacks"]) == 1
|
||||||
|
feedback = data["action_feedbacks"][0]
|
||||||
|
assert feedback["success"] is True
|
||||||
|
assert feedback["user"] == "alice"
|
||||||
|
# 初始 3 点,消耗 1 点,恢复 1 点 = 3 点
|
||||||
|
# 但消耗在恢复之后,所以是 3 - 1 = 2
|
||||||
|
assert feedback["remaining_ap"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_action_points_rejected(client):
|
||||||
|
"""测试行动点不足时拒绝执行"""
|
||||||
|
# 重置状态,设置 alice 行动点为 0,last_action_tick 为当前 tick
|
||||||
|
# 这样在下一个 tick 恢复后只有 1 点
|
||||||
|
state = get_default_state()
|
||||||
|
state.tick = 10
|
||||||
|
state.agents["alice"].action_points = 0
|
||||||
|
state.agents["alice"].last_action_tick = 10 # 刚行动过,不会恢复
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
# 尝试投票(需要 1 点),但恢复后有 1 点
|
||||||
|
# 为了测试不足,我们需要让 last_action_tick = tick,这样不会恢复
|
||||||
|
# 但 regenerate 检查的是 tick - last_action_tick >= 1
|
||||||
|
# tick 会先 +1 变成 11,11 - 10 = 1 >= 1,所以会恢复
|
||||||
|
|
||||||
|
# 重新设计:设置 last_action_tick 为未来的 tick
|
||||||
|
state = get_default_state()
|
||||||
|
state.tick = 10
|
||||||
|
state.agents["alice"].action_points = 0
|
||||||
|
state.agents["alice"].last_action_tick = 11 # 未来 tick,不会恢复
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
events = [
|
||||||
|
{"type": "vote", "faction": "optimists", "text": "", "user": "alice", "ts": 10}
|
||||||
|
]
|
||||||
|
resp = client.post("/step", json={"events": events})
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# 验证被拒绝
|
||||||
|
assert len(data["action_feedbacks"]) == 1
|
||||||
|
feedback = data["action_feedbacks"][0]
|
||||||
|
assert feedback["success"] is False
|
||||||
|
assert "insufficient" in feedback["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_points_regenerate_per_tick(client):
|
||||||
|
"""测试行动点每 tick 恢复"""
|
||||||
|
# 设置 alice 行动点为 1
|
||||||
|
state = get_default_state()
|
||||||
|
state.agents["alice"].action_points = 1
|
||||||
|
state.agents["alice"].last_action_tick = 0
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
# 执行一个 tick(无事件)
|
||||||
|
resp = client.post("/step", json={"events": []})
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# 行动点应该恢复到 2(1 + 1)
|
||||||
|
assert data["world_state"]["agents"]["alice"]["action_points"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_points_not_exceed_max(client):
|
||||||
|
"""测试行动点不超过最大值"""
|
||||||
|
# 设置 alice 行动点为 3(已满)
|
||||||
|
state = get_default_state()
|
||||||
|
state.agents["alice"].action_points = 3
|
||||||
|
state.agents["alice"].max_action_points = 3
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
# 执行多个 tick
|
||||||
|
for _ in range(3):
|
||||||
|
client.post("/step", json={"events": []})
|
||||||
|
|
||||||
|
resp = client.post("/step", json={"events": []})
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# 行动点不应超过 max
|
||||||
|
assert data["world_state"]["agents"]["alice"]["action_points"] <= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_cost_action_always_allowed(client):
|
||||||
|
"""测试 0 消耗的行动始终允许"""
|
||||||
|
# 设置 alice 行动点为 0
|
||||||
|
state = get_default_state()
|
||||||
|
state.agents["alice"].action_points = 0
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
# comment 类型消耗 0 点
|
||||||
|
events = [
|
||||||
|
{"type": "comment", "text": "hello", "user": "alice", "ts": 1}
|
||||||
|
]
|
||||||
|
resp = client.post("/step", json={"events": events})
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# 应该成功执行
|
||||||
|
assert len(data["action_feedbacks"]) == 1
|
||||||
|
feedback = data["action_feedbacks"][0]
|
||||||
|
assert feedback["success"] is True
|
||||||
|
|||||||
234
web/index.html
234
web/index.html
@@ -37,6 +37,21 @@
|
|||||||
.btn-quick { background: #3f3f46; color: #a1a1aa; }
|
.btn-quick { background: #3f3f46; color: #a1a1aa; }
|
||||||
.btn-quick:hover { background: #52525b; color: #e4e4e7; }
|
.btn-quick:hover { background: #52525b; color: #e4e4e7; }
|
||||||
|
|
||||||
|
/* 投票按钮 */
|
||||||
|
.vote-section { display: flex; gap: 8px; margin-top: 10px; align-items: center; }
|
||||||
|
.vote-label { font-size: 12px; color: #71717a; }
|
||||||
|
.btn-vote { padding: 8px 16px; font-weight: 600; }
|
||||||
|
.btn-vote-opt { background: #166534; color: #86efac; }
|
||||||
|
.btn-vote-opt:hover { background: #15803d; }
|
||||||
|
.btn-vote-fear { background: #7f1d1d; color: #fca5a5; }
|
||||||
|
.btn-vote-fear:hover { background: #991b1b; }
|
||||||
|
|
||||||
|
/* 投票统计 */
|
||||||
|
.votes-display { display: flex; gap: 16px; margin-top: 8px; font-size: 13px; }
|
||||||
|
.vote-count { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.vote-count.opt { color: #86efac; }
|
||||||
|
.vote-count.fear { color: #fca5a5; }
|
||||||
|
|
||||||
/* 能量条 - 极简 */
|
/* 能量条 - 极简 */
|
||||||
#global-meter { width: 100%; height: 16px; background: #27272a; border-radius: 8px; margin-bottom: 16px; overflow: hidden; }
|
#global-meter { width: 100%; height: 16px; background: #27272a; border-radius: 8px; margin-bottom: 16px; overflow: hidden; }
|
||||||
#meter-bar { height: 100%; width: 0%; background: linear-gradient(90deg, #00ffcc, #ff0066); transition: width 0.3s ease; }
|
#meter-bar { height: 100%; width: 0%; background: linear-gradient(90deg, #00ffcc, #ff0066); transition: width 0.3s ease; }
|
||||||
@@ -71,6 +86,20 @@
|
|||||||
.emotion-calm { background: #1e3a5f; color: #93c5fd; }
|
.emotion-calm { background: #1e3a5f; color: #93c5fd; }
|
||||||
.agent-info { font-size: 13px; color: #a1a1aa; }
|
.agent-info { font-size: 13px; color: #a1a1aa; }
|
||||||
|
|
||||||
|
/* 行动点 */
|
||||||
|
.agent-ap { display: flex; align-items: center; gap: 4px; margin-top: 6px; }
|
||||||
|
.ap-dots { display: flex; gap: 3px; }
|
||||||
|
.ap-dot { width: 8px; height: 8px; border-radius: 50%; background: #3f3f46; }
|
||||||
|
.ap-dot.filled { background: #60a5fa; }
|
||||||
|
.ap-label { font-size: 11px; color: #71717a; }
|
||||||
|
|
||||||
|
/* 行动反馈提示 */
|
||||||
|
#action-feedback { position: fixed; bottom: 20px; right: 20px; z-index: 100; }
|
||||||
|
.feedback-toast { padding: 10px 16px; border-radius: 8px; margin-top: 8px; font-size: 13px; animation: fadeIn 0.3s ease; }
|
||||||
|
.feedback-toast.success { background: #166534; color: #86efac; border: 1px solid #22c55e; }
|
||||||
|
.feedback-toast.fail { background: #7f1d1d; color: #fca5a5; border: 1px solid #ef4444; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
/* 行动日志 */
|
/* 行动日志 */
|
||||||
.actions-list { max-height: 300px; overflow-y: auto; }
|
.actions-list { max-height: 300px; overflow-y: auto; }
|
||||||
.action-item { padding: 8px; background: #27272a; border-radius: 6px; margin-bottom: 6px; font-size: 13px; }
|
.action-item { padding: 8px; background: #27272a; border-radius: 6px; margin-bottom: 6px; font-size: 13px; }
|
||||||
@@ -86,6 +115,46 @@
|
|||||||
.faction-fearful { background: #ef4444; color: #450a0a; }
|
.faction-fearful { background: #ef4444; color: #450a0a; }
|
||||||
.factions-legend { display: flex; gap: 12px; margin-top: 8px; font-size: 11px; color: #a1a1aa; }
|
.factions-legend { display: flex; gap: 12px; margin-top: 8px; font-size: 11px; color: #a1a1aa; }
|
||||||
.legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 4px; }
|
.legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 4px; }
|
||||||
|
|
||||||
|
/* 阵营能量条 */
|
||||||
|
.faction-power { display: flex; gap: 12px; margin-top: 12px; }
|
||||||
|
.power-item { flex: 1; }
|
||||||
|
.power-label { font-size: 11px; color: #a1a1aa; margin-bottom: 4px; display: flex; justify-content: space-between; }
|
||||||
|
.power-bar { height: 8px; background: #27272a; border-radius: 4px; overflow: hidden; }
|
||||||
|
.power-fill { height: 100%; transition: width 0.3s ease; }
|
||||||
|
.power-fill.optimist { background: linear-gradient(90deg, #22c55e, #86efac); }
|
||||||
|
.power-fill.fearful { background: linear-gradient(90deg, #ef4444, #fca5a5); }
|
||||||
|
|
||||||
|
/* 阵营技能提示 */
|
||||||
|
#faction-event { background: #1e3a5f; border: 1px solid #60a5fa; border-radius: 8px; padding: 12px; margin-bottom: 16px; display: none; font-size: 14px; }
|
||||||
|
|
||||||
|
/* 剧情线 */
|
||||||
|
.story-arcs { margin-top: 16px; padding-top: 12px; border-top: 1px solid #3f3f46; }
|
||||||
|
.arc-item { margin-bottom: 10px; }
|
||||||
|
.arc-header { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
|
||||||
|
.arc-name { color: #a1a1aa; }
|
||||||
|
.arc-stage { color: #f59e0b; }
|
||||||
|
.arc-bar { height: 6px; background: #27272a; border-radius: 3px; overflow: hidden; }
|
||||||
|
.arc-fill { height: 100%; background: linear-gradient(90deg, #a78bfa, #f472b6); transition: width 0.3s ease; }
|
||||||
|
|
||||||
|
/* 剧情事件提示 */
|
||||||
|
#story-event { background: #3b0764; border: 1px solid #a78bfa; border-radius: 8px; padding: 12px; margin-bottom: 16px; display: none; font-size: 14px; }
|
||||||
|
|
||||||
|
/* 技能树 */
|
||||||
|
.skill-trees { margin-top: 16px; padding-top: 12px; border-top: 1px solid #3f3f46; }
|
||||||
|
.skill-trees-title { font-size: 11px; color: #71717a; text-transform: uppercase; margin-bottom: 8px; letter-spacing: 0.05em; }
|
||||||
|
.skill-trees-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.skill-tree { background: #27272a; border-radius: 6px; padding: 10px; }
|
||||||
|
.skill-tree-header { font-size: 11px; font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 4px; }
|
||||||
|
.skill-tree-header.opt { color: #86efac; }
|
||||||
|
.skill-tree-header.fear { color: #fca5a5; }
|
||||||
|
.skill-item { display: flex; align-items: center; gap: 6px; padding: 6px 8px; margin-bottom: 4px; border-radius: 4px; font-size: 11px; background: #1a1a2e; }
|
||||||
|
.skill-item:last-child { margin-bottom: 0; }
|
||||||
|
.skill-item.unlocked { border-left: 2px solid #22c55e; }
|
||||||
|
.skill-item.locked { border-left: 2px solid #3f3f46; opacity: 0.7; }
|
||||||
|
.skill-icon { font-size: 12px; }
|
||||||
|
.skill-name { flex: 1; color: #e4e4e7; }
|
||||||
|
.skill-cost { font-size: 10px; color: #71717a; background: #3f3f46; padding: 2px 6px; border-radius: 3px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -109,6 +178,15 @@
|
|||||||
<button class="btn-quick" onclick="sendQuick('混乱')">混乱</button>
|
<button class="btn-quick" onclick="sendQuick('混乱')">混乱</button>
|
||||||
<button class="btn-quick" onclick="sendQuick('下雨')">下雨</button>
|
<button class="btn-quick" onclick="sendQuick('下雨')">下雨</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="vote-section">
|
||||||
|
<span class="vote-label">投票:</span>
|
||||||
|
<button class="btn-vote btn-vote-opt" onclick="sendVote('optimists')">👍 乐观派</button>
|
||||||
|
<button class="btn-vote btn-vote-fear" onclick="sendVote('fearful')">👎 恐惧派</button>
|
||||||
|
<div class="votes-display">
|
||||||
|
<span class="vote-count opt" id="vote-opt">乐观: 0</span>
|
||||||
|
<span class="vote-count fear" id="vote-fear">恐惧: 0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ① 能量条 -->
|
<!-- ① 能量条 -->
|
||||||
@@ -117,11 +195,20 @@
|
|||||||
<!-- ② 世界事件提示 -->
|
<!-- ② 世界事件提示 -->
|
||||||
<div id="world-event"></div>
|
<div id="world-event"></div>
|
||||||
|
|
||||||
|
<!-- ② 阵营技能提示 -->
|
||||||
|
<div id="faction-event"></div>
|
||||||
|
|
||||||
|
<!-- ② 剧情事件提示 -->
|
||||||
|
<div id="story-event"></div>
|
||||||
|
|
||||||
<!-- ③ 持续影响 -->
|
<!-- ③ 持续影响 -->
|
||||||
<div id="world-effects"></div>
|
<div id="world-effects"></div>
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
<!-- 主内容区 -->
|
||||||
<div class="main-grid">
|
<div class="main-grid">
|
||||||
|
|
||||||
|
<!-- 行动反馈提示 -->
|
||||||
|
<div id="action-feedback"></div>
|
||||||
<div>
|
<div>
|
||||||
<div class="card" style="margin-bottom: 16px;">
|
<div class="card" style="margin-bottom: 16px;">
|
||||||
<div class="card-title">世界状态</div>
|
<div class="card-title">世界状态</div>
|
||||||
@@ -133,9 +220,14 @@
|
|||||||
<div class="factions-bar" id="factions-bar"></div>
|
<div class="factions-bar" id="factions-bar"></div>
|
||||||
<div class="factions-legend">
|
<div class="factions-legend">
|
||||||
<span><span class="legend-dot" style="background:#22c55e"></span>乐观派</span>
|
<span><span class="legend-dot" style="background:#22c55e"></span>乐观派</span>
|
||||||
<span><span class="legend-dot" style="background:#71717a"></span>中立</span>
|
|
||||||
<span><span class="legend-dot" style="background:#ef4444"></span>恐惧派</span>
|
<span><span class="legend-dot" style="background:#ef4444"></span>恐惧派</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 阵营能量条 -->
|
||||||
|
<div class="faction-power" id="faction-power"></div>
|
||||||
|
<!-- 剧情线 -->
|
||||||
|
<div class="story-arcs" id="story-arcs"></div>
|
||||||
|
<!-- 技能树 -->
|
||||||
|
<div class="skill-trees" id="skill-trees"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">居民</div>
|
<div class="card-title">居民</div>
|
||||||
@@ -185,18 +277,57 @@
|
|||||||
ws.send(JSON.stringify({ type: 'comment', text, user: 'viewer' }));
|
ws.send(JSON.stringify({ type: 'comment', text, user: 'viewer' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发送投票
|
||||||
|
function sendVote(faction) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(JSON.stringify({ type: 'vote', faction, text: '', user: 'viewer' }));
|
||||||
|
}
|
||||||
|
|
||||||
// 更新 UI
|
// 更新 UI
|
||||||
const weatherMap = { sunny: '☀️', rainy: '🌧️' };
|
const weatherMap = { sunny: '☀️', rainy: '🌧️' };
|
||||||
const actionHistory = [];
|
const actionHistory = [];
|
||||||
|
|
||||||
|
// 显示行动反馈提示
|
||||||
|
function showActionFeedbacks(feedbacks) {
|
||||||
|
const container = document.getElementById('action-feedback');
|
||||||
|
feedbacks.forEach(fb => {
|
||||||
|
if (!fb.user) return;
|
||||||
|
const cls = fb.success ? 'success' : 'fail';
|
||||||
|
const icon = fb.success ? '✓' : '✗';
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `feedback-toast ${cls}`;
|
||||||
|
toast.textContent = `${icon} ${fb.user}: ${fb.reason} (AP: ${fb.remaining_ap})`;
|
||||||
|
container.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染技能列表
|
||||||
|
function renderSkills(skills, names) {
|
||||||
|
return Object.entries(skills).map(([id, s]) => {
|
||||||
|
const cls = s.unlocked ? 'unlocked' : 'locked';
|
||||||
|
const icon = s.unlocked ? '✅' : '🔒';
|
||||||
|
return `<div class="skill-item ${cls}">
|
||||||
|
<span class="skill-icon">${icon}</span>
|
||||||
|
<span class="skill-name">${names[id] || id}</span>
|
||||||
|
<span class="skill-cost">${s.cost}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
function updateUI(data) {
|
function updateUI(data) {
|
||||||
const { world_state, actions, global_event } = data;
|
const { world_state, actions, global_event, triggered_faction_event, story_event, action_feedbacks } = data;
|
||||||
|
|
||||||
// 世界状态
|
// 世界状态
|
||||||
document.getElementById('tick').textContent = world_state.tick;
|
document.getElementById('tick').textContent = world_state.tick;
|
||||||
document.getElementById('weather').textContent = weatherMap[world_state.weather] || world_state.weather;
|
document.getElementById('weather').textContent = weatherMap[world_state.weather] || world_state.weather;
|
||||||
document.getElementById('mood').textContent = world_state.town_mood;
|
document.getElementById('mood').textContent = world_state.town_mood;
|
||||||
|
|
||||||
|
// 行动反馈提示
|
||||||
|
if (action_feedbacks && action_feedbacks.length > 0) {
|
||||||
|
showActionFeedbacks(action_feedbacks);
|
||||||
|
}
|
||||||
|
|
||||||
// ① 能量条
|
// ① 能量条
|
||||||
if (world_state.global_meter) {
|
if (world_state.global_meter) {
|
||||||
const meter = world_state.global_meter;
|
const meter = world_state.global_meter;
|
||||||
@@ -204,6 +335,12 @@
|
|||||||
document.getElementById('meter-bar').style.width = percent + '%';
|
document.getElementById('meter-bar').style.width = percent + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ① 投票统计
|
||||||
|
if (world_state.votes) {
|
||||||
|
document.getElementById('vote-opt').textContent = '乐观: ' + world_state.votes.optimists;
|
||||||
|
document.getElementById('vote-fear').textContent = '恐惧: ' + world_state.votes.fearful;
|
||||||
|
}
|
||||||
|
|
||||||
// ② 世界事件
|
// ② 世界事件
|
||||||
const eventEl = document.getElementById('world-event');
|
const eventEl = document.getElementById('world-event');
|
||||||
if (global_event?.triggered && global_event.event) {
|
if (global_event?.triggered && global_event.event) {
|
||||||
@@ -213,6 +350,27 @@
|
|||||||
eventEl.style.display = 'none';
|
eventEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ② 阵营技能提示
|
||||||
|
const factionEventEl = document.getElementById('faction-event');
|
||||||
|
if (triggered_faction_event?.type) {
|
||||||
|
const msg = triggered_faction_event.type === 'festival'
|
||||||
|
? '🎉 乐观派发动了节日庆典!'
|
||||||
|
: '😱 恐惧派引发了恐慌!';
|
||||||
|
factionEventEl.textContent = msg;
|
||||||
|
factionEventEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
factionEventEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 剧情事件提示
|
||||||
|
const storyEventEl = document.getElementById('story-event');
|
||||||
|
if (story_event?.triggered) {
|
||||||
|
storyEventEl.textContent = '📖 ' + story_event.event_name + ':' + story_event.description;
|
||||||
|
storyEventEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
storyEventEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// ③ 持续影响
|
// ③ 持续影响
|
||||||
const effectsEl = document.getElementById('world-effects');
|
const effectsEl = document.getElementById('world-effects');
|
||||||
if (world_state.world_effects?.length > 0) {
|
if (world_state.world_effects?.length > 0) {
|
||||||
@@ -226,24 +384,88 @@
|
|||||||
// ④ 派系分布
|
// ④ 派系分布
|
||||||
if (world_state.factions) {
|
if (world_state.factions) {
|
||||||
const f = world_state.factions;
|
const f = world_state.factions;
|
||||||
const total = f.optimists + f.neutral + f.fearful || 1;
|
const optimistCount = f.optimists.members.length;
|
||||||
|
const fearfulCount = f.fearful.members.length;
|
||||||
|
const total = optimistCount + fearfulCount || 1;
|
||||||
const bar = document.getElementById('factions-bar');
|
const bar = document.getElementById('factions-bar');
|
||||||
bar.innerHTML = '';
|
bar.innerHTML = '';
|
||||||
if (f.optimists > 0) bar.innerHTML += `<div class="faction-segment faction-optimists" style="width:${f.optimists/total*100}%">${f.optimists}</div>`;
|
if (optimistCount > 0) bar.innerHTML += `<div class="faction-segment faction-optimists" style="width:${optimistCount/total*100}%">${optimistCount}</div>`;
|
||||||
if (f.neutral > 0) bar.innerHTML += `<div class="faction-segment faction-neutral" style="width:${f.neutral/total*100}%">${f.neutral}</div>`;
|
if (fearfulCount > 0) bar.innerHTML += `<div class="faction-segment faction-fearful" style="width:${fearfulCount/total*100}%">${fearfulCount}</div>`;
|
||||||
if (f.fearful > 0) bar.innerHTML += `<div class="faction-segment faction-fearful" style="width:${f.fearful/total*100}%">${f.fearful}</div>`;
|
|
||||||
|
// ⑤ 阵营能量条
|
||||||
|
const powerEl = document.getElementById('faction-power');
|
||||||
|
const optPct = Math.min(100, f.optimists.power / f.optimists.threshold * 100);
|
||||||
|
const fearPct = Math.min(100, f.fearful.power / f.fearful.threshold * 100);
|
||||||
|
powerEl.innerHTML = `
|
||||||
|
<div class="power-item">
|
||||||
|
<div class="power-label"><span>乐观派能量</span><span>${f.optimists.power}/${f.optimists.threshold}</span></div>
|
||||||
|
<div class="power-bar"><div class="power-fill optimist" style="width:${optPct}%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="power-item">
|
||||||
|
<div class="power-label"><span>恐惧派能量</span><span>${f.fearful.power}/${f.fearful.threshold}</span></div>
|
||||||
|
<div class="power-bar"><div class="power-fill fearful" style="width:${fearPct}%"></div></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑥ 剧情线进度
|
||||||
|
if (world_state.story_arcs) {
|
||||||
|
const arcsEl = document.getElementById('story-arcs');
|
||||||
|
const arcNames = { civil_unrest: '民众骚乱', golden_age: '黄金时代' };
|
||||||
|
arcsEl.innerHTML = Object.entries(world_state.story_arcs).map(([id, arc]) => {
|
||||||
|
const pct = Math.min(100, arc.progress / arc.threshold * 100);
|
||||||
|
return `<div class="arc-item">
|
||||||
|
<div class="arc-header">
|
||||||
|
<span class="arc-name">${arcNames[id] || id}</span>
|
||||||
|
<span class="arc-stage">阶段 ${arc.stage}</span>
|
||||||
|
</div>
|
||||||
|
<div class="arc-bar"><div class="arc-fill" style="width:${pct}%"></div></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑦ 技能树
|
||||||
|
if (world_state.faction_skills) {
|
||||||
|
const skillsEl = document.getElementById('skill-trees');
|
||||||
|
const fs = world_state.faction_skills;
|
||||||
|
const skillNames = {
|
||||||
|
festival_boost: '节日增幅', unity: '团结一心',
|
||||||
|
panic_spread: '恐慌蔓延', control: '压制控制'
|
||||||
|
};
|
||||||
|
skillsEl.innerHTML = `
|
||||||
|
<div class="skill-trees-title">技能树</div>
|
||||||
|
<div class="skill-trees-grid">
|
||||||
|
<div class="skill-tree">
|
||||||
|
<div class="skill-tree-header opt">🌟 乐观派</div>
|
||||||
|
${renderSkills(fs.optimists.skills, skillNames)}
|
||||||
|
</div>
|
||||||
|
<div class="skill-tree">
|
||||||
|
<div class="skill-tree-header fear">💀 恐惧派</div>
|
||||||
|
${renderSkills(fs.fearful.skills, skillNames)}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 角色
|
// 角色
|
||||||
const agentsEl = document.getElementById('agents');
|
const agentsEl = document.getElementById('agents');
|
||||||
agentsEl.innerHTML = '';
|
agentsEl.innerHTML = '';
|
||||||
for (const [id, agent] of Object.entries(world_state.agents)) {
|
for (const [id, agent] of Object.entries(world_state.agents)) {
|
||||||
|
const ap = agent.action_points || 0;
|
||||||
|
const maxAp = agent.max_action_points || 3;
|
||||||
|
const apDots = Array(maxAp).fill(0).map((_, i) =>
|
||||||
|
`<span class="ap-dot ${i < ap ? 'filled' : ''}"></span>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
agentsEl.innerHTML += `
|
agentsEl.innerHTML += `
|
||||||
<div class="agent">
|
<div class="agent">
|
||||||
<div class="agent-header">
|
<div class="agent-header">
|
||||||
<span class="agent-name">${id}</span>
|
<span class="agent-name">${id}</span>
|
||||||
<span class="agent-emotion emotion-${agent.emotion}">${agent.emotion}</span>
|
<span class="agent-emotion emotion-${agent.emotion}">${agent.emotion}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="agent-ap">
|
||||||
|
<span class="ap-label">AP:</span>
|
||||||
|
<div class="ap-dots">${apDots}</div>
|
||||||
|
<span class="ap-label">${ap}/${maxAp}</span>
|
||||||
|
</div>
|
||||||
<div class="agent-info">记忆: ${agent.memory.slice(-2).join(' | ') || '-'}</div>
|
<div class="agent-info">记忆: ${agent.memory.slice(-2).join(' | ') || '-'}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user