update death

This commit is contained in:
bridge
2025-12-01 02:05:11 +08:00
parent f047251c0d
commit 39f158bbe8
18 changed files with 185 additions and 66 deletions

View File

@@ -9,6 +9,7 @@ from src.classes.battle import decide_battle, get_assassination_success_rate
from src.classes.story_teller import StoryTeller
from src.classes.normalize import normalize_avatar_name
from src.classes.death import handle_death
from src.classes.death_reason import DeathReason
from src.classes.kill_and_grab import kill_and_grab
if TYPE_CHECKING:
@@ -116,7 +117,7 @@ class Assassinate(InstantAction):
story_event = Event(self.world.month_stamp, story, related_avatars=rel_ids, is_story=True)
# 死亡清理
handle_death(self.world, target)
handle_death(self.world, target, DeathReason.BATTLE)
return [result_event, story_event]
@@ -153,7 +154,7 @@ class Assassinate(InstantAction):
story_event = Event(self.world.month_stamp, story, related_avatars=rel_ids, is_story=True)
if is_fatal:
handle_death(self.world, loser)
handle_death(self.world, loser, DeathReason.BATTLE)
return [result_event, story_event]

View File

@@ -7,6 +7,7 @@ from src.classes.battle import decide_battle, get_effective_strength_pair
from src.classes.story_teller import StoryTeller
from src.classes.normalize import normalize_avatar_name
from src.classes.death import handle_death
from src.classes.death_reason import DeathReason
from src.classes.kill_and_grab import kill_and_grab
class Attack(InstantAction):
@@ -109,6 +110,6 @@ class Attack(InstantAction):
# 如果死亡,执行死亡清理(在故事生成后,保证关系数据可用)
if is_fatal:
handle_death(self.world, loser)
handle_death(self.world, loser, DeathReason.BATTLE)
return [result_event, story_event]

View File

@@ -84,7 +84,8 @@ class Age:
years_over_lifespan = self.age - expected
# 基础概率每超过1年增加0.01的概率
death_probability = min(years_over_lifespan * 0.01, 0.1)
prob_add = 0.01
death_probability = min(years_over_lifespan * prob_add, 0.1)
return death_probability

View File

@@ -114,6 +114,12 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin):
nickname: Optional[Nickname] = None
# 自定义头像ID如果设置优先使用此ID显示头像
custom_pic_id: Optional[int] = None
# 死亡状态
is_dead: bool = False
# 死亡信息:{ "time": MonthStamp, "reason": str, "location": (x, y) }
death_info: Optional[dict] = None
# 当月/当步新设动作标记:在 commit_next_plan 设为 True首次 tick_action 后清为 False
_new_action_set_this_step: bool = False
# 动作冷却:记录动作类名 -> 上次完成月戳
@@ -122,6 +128,8 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin):
def join_sect(self, sect: Sect, rank: "SectRank") -> None:
"""加入宗门"""
if self.is_dead:
return
if self.sect:
self.leave_sect()
self.sect = sect
@@ -137,6 +145,38 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin):
self.sect = None
self.sect_rank = None
def set_dead(self, reason: str, time: MonthStamp) -> None:
"""
设置角色死亡状态。
Args:
reason: 死亡原因
time: 死亡时间
"""
if self.is_dead:
return
self.is_dead = True
self.death_info = {
"time": int(time),
"reason": reason,
"location": (self.pos_x, self.pos_y)
}
# 清空所有计划和当前动作
self.planned_actions.clear()
self.current_action = None
self._pending_events.clear()
self.thinking = ""
self.short_term_objective = ""
# 退出宗门(保留职位记录还是清除?通常死人不再担任职位)
# 但为了历史记录,也许可以保留 sect 字段,但从宗门成员列表中移除
if self.sect:
self.sect.remove_member(self)
# 不清除 self.sect 和 self.sect_rank作为生平记录保留
def __post_init__(self):
"""
在Avatar创建后自动初始化tile和HP
@@ -319,6 +359,8 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin):
"long_term_objective": self.long_term_objective.content if self.long_term_objective else "",
"nickname": self.nickname.value if self.nickname else None,
"nickname_reason": self.nickname.reason if self.nickname else None,
"is_dead": self.is_dead,
"death_info": self.death_info,
}
# 复杂对象结构化

View File

@@ -26,6 +26,12 @@ class AvatarManager:
same_region.append(other)
return same_region
def get_living_avatars(self) -> List["Avatar"]:
"""
返回所有存活的角色列表。
"""
return [avatar for avatar in self.avatars.values() if not avatar.is_dead]
def get_observable_avatars(self, avatar: "Avatar") -> List["Avatar"]:
"""
返回处于 avatar 交互范围内的其他角色列表(不含自己)。

View File

@@ -1,16 +1,27 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Union
from src.classes.death_reason import DeathReason
if TYPE_CHECKING:
from src.classes.world import World
from src.classes.avatar import Avatar
def handle_death(world: World, avatar: Avatar) -> None:
def handle_death(world: World, avatar: Avatar, reason: Union[str, DeathReason] = DeathReason.UNKNOWN) -> None:
"""
处理角色死亡的统一入口。
负责将角色从世界管理器中移除,并处理相关的清理工作(如关系解除已在 remove_avatar 中实现)
注意:本函数不负责生成死亡事件文本,调用者应在调用前生成相应的 Event。
负责将角色标记为死亡,清理行动队列,但保留角色数据
Args:
world: 世界对象
avatar: 死亡的角色
reason: 死亡原因DeathReason枚举或字符串
"""
# 从管理器中移除角色remove_avatar 内部会自动清理双向关系)
world.avatar_manager.remove_avatar(avatar.id)
# 如果传入的是枚举,转为字符串值
reason_str = reason.value if isinstance(reason, DeathReason) else str(reason)
# 标记为死亡(软删除)
avatar.set_dead(reason_str, world.month_stamp)
# 可以在这里触发其他逻辑,比如检查是否有继承人等

View File

@@ -0,0 +1,11 @@
from enum import Enum
class DeathReason(Enum):
OLD_AGE = "老死"
BATTLE = "战死"
SERIOUS_INJURY = "重伤"
UNKNOWN = "未知"
def __str__(self) -> str:
return self.value

View File

@@ -255,16 +255,19 @@ async def game_loop():
# 执行一步
events = await sim.step()
# 找出新诞生的角色 ID
# 找出新诞生的角色 ID 和 刚死亡的角色 ID
newly_born_ids = set()
newly_dead_ids = set()
for e in events:
if "晋升为修士" in e.content and e.related_avatars:
newly_born_ids.update(e.related_avatars)
if ("身亡" in e.content or "老死" in e.content) and e.related_avatars:
newly_dead_ids.update(e.related_avatars)
avatar_updates = []
# 为了避免重复发送大量数据,我们区分处理:
# - 新角色:发送完整数据
# - 新角色/刚死角色:发送完整数据(或关键状态更新)
# - 旧角色:只发送位置 (x, y)(限制数量)
# 1. 发送新角色的完整信息
@@ -276,15 +279,29 @@ async def game_loop():
"name": a.name,
"x": int(getattr(a, "pos_x", 0)),
"y": int(getattr(a, "pos_y", 0)),
"gender": a.gender.value, # 使用 value (male/female) 而不是 str (中文)
"gender": a.gender.value,
"pic_id": resolve_avatar_pic_id(a),
"action": getattr(a, "current_action", {}).get("name", "发呆") if hasattr(a, "current_action") and a.current_action else "发呆"
"action": getattr(a, "current_action", {}).get("name", "发呆") if hasattr(a, "current_action") and a.current_action else "发呆",
"is_dead": False
})
# 2. 常规位置更新(暂时只发前 50 个旧角色,减少数据量)
# 2. 发送刚死角色的状态更新
for aid in newly_dead_ids:
# 注意:死人可能不在 get_living_avatars() 里,但还在 avatars 里
a = world.avatar_manager.avatars.get(aid)
if a:
avatar_updates.append({
"id": str(a.id),
"name": a.name, # 名字也带上,防止前端没数据
"is_dead": True,
"action": "已故"
})
# 3. 常规位置更新(暂时只发前 50 个旧角色,减少数据量)
limit = 50
count = 0
for a in world.avatar_manager.avatars.values():
# 只遍历活人更新位置
for a in world.avatar_manager.get_living_avatars():
# 如果是新角色,已经在上面处理过了,跳过
if a.id in newly_born_ids:
continue

View File

@@ -148,6 +148,10 @@ class AvatarLoadMixin:
# 恢复绰号
from src.classes.nickname_data import Nickname
avatar.nickname = Nickname.from_dict(data.get("nickname"))
# 恢复死亡状态
avatar.is_dead = data.get("is_dead", False)
avatar.death_info = data.get("death_info")
# 设置行动与AI
avatar.thinking = data.get("thinking", "")

View File

@@ -251,7 +251,10 @@ class MortalPlanner:
plan.pos_y = random.randint(0, world.map.height - 1)
if existing_avatars is None:
existing_avatars = list(world.avatar_manager.avatars.values())
existing_avatars = world.avatar_manager.get_living_avatars()
else:
existing_avatars = [av for av in existing_avatars if not av.is_dead]
if existed_sects is None:
try:
from src.classes.sect import sects_by_id as _sects_by_id

View File

@@ -90,6 +90,8 @@ class AvatarSaveMixin:
"persona_ids": [p.id for p in self.personas] if self.personas else [],
"appearance": self.appearance.level,
"nickname": self.nickname.to_dict() if self.nickname else None,
"is_dead": self.is_dead,
"death_info": self.death_info,
# 行动与AI
"current_action": current_action_dict,

View File

@@ -16,6 +16,7 @@ from src.classes.fortune import try_trigger_fortune
from src.classes.celestial_phenomenon import get_random_celestial_phenomenon
from src.classes.long_term_objective import process_avatar_long_term_objective
from src.classes.death import handle_death
from src.classes.death_reason import DeathReason
class Simulator:
def __init__(self, world: World):
@@ -28,7 +29,7 @@ class Simulator:
将 AI 的决策结果加载为角色的计划链。
"""
avatars_to_decide = []
for avatar in list(self.world.avatar_manager.avatars.values()):
for avatar in self.world.avatar_manager.get_living_avatars():
if avatar.current_action is None and not avatar.has_plans():
avatars_to_decide.append(avatar)
if not avatars_to_decide:
@@ -45,7 +46,7 @@ class Simulator:
提交阶段:为空闲角色提交计划中的下一个可执行动作,返回开始事件集合。
"""
events = []
for avatar in list(self.world.avatar_manager.avatars.values()):
for avatar in self.world.avatar_manager.get_living_avatars():
if avatar.current_action is None:
start_event = avatar.commit_next_plan()
if start_event is not None and not is_null_event(start_event):
@@ -60,7 +61,7 @@ class Simulator:
MAX_LOCAL_ROUNDS = 3
for _ in range(MAX_LOCAL_ROUNDS):
new_action_happened = False
for avatar_id, avatar in list(self.world.avatar_manager.avatars.items()):
for avatar in self.world.avatar_manager.get_living_avatars():
# 本轮执行前若标记为新设,则清理,执行后由 Avatar 再统一清除
if getattr(avatar, "_new_action_set_this_step", False):
new_action_happened = True
@@ -78,28 +79,30 @@ class Simulator:
def _phase_resolve_death(self):
"""
结算死亡:
- 战斗死亡已在 Action 中结算,此处不再重复(因为已从 avatars 中移除)
- 战斗死亡已在 Action 中结算
- 此时剩下的 avatars 都是存活的,只需检查非战斗因素(如老死、被动掉血)
"""
events = []
# 遍历时可能修改字典,使用 list() 复制
for avatar_id, avatar in list(self.world.avatar_manager.avatars.items()):
for avatar in self.world.avatar_manager.get_living_avatars():
is_dead = False
reason = ""
reason_str = ""
death_reason = DeathReason.UNKNOWN
# 优先判定重伤(可能是被动效果导致)
if avatar.hp <= 0:
if avatar.hp.cur <= 0: # 注意:这里应该是 avatar.hp.cur 或者 avatar.hp <= 0 取决于 HP 类的实现,原代码是 avatar.hp <= 0
is_dead = True
reason = f"{avatar.name} 因重伤不治身亡"
reason_str = f"{avatar.name} 因重伤不治身亡"
death_reason = DeathReason.SERIOUS_INJURY
# 其次判定寿元
elif avatar.death_by_old_age():
is_dead = True
reason = f"{avatar.name} 老死了,时年{avatar.age.get_age()}"
reason_str = f"{avatar.name} 老死了,时年{avatar.age.get_age()}"
death_reason = DeathReason.OLD_AGE
if is_dead:
event = Event(self.world.month_stamp, reason, related_avatars=[avatar.id])
event = Event(self.world.month_stamp, reason_str, related_avatars=[avatar.id])
events.append(event)
handle_death(self.world, avatar)
handle_death(self.world, avatar, death_reason)
return events
@@ -108,12 +111,13 @@ class Simulator:
更新存活角色年龄,并以一定概率生成新修士,返回期间产生的事件集合。
"""
events = []
for avatar_id, avatar in self.world.avatar_manager.avatars.items():
for avatar in self.world.avatar_manager.get_living_avatars():
avatar.update_age(self.world.month_stamp)
if random.random() < self.birth_rate:
age = random.randint(16, 60)
gender = random.choice(list(Gender))
name = get_random_name(gender)
# create_random_mortal 内部会获取 existing_avatars需要确保它处理活人
new_avatar = create_random_mortal(self.world, self.world.month_stamp, name, Age(age, Realm.Qi_Refinement))
self.world.avatar_manager.avatars[new_avatar.id] = new_avatar
event = Event(self.world.month_stamp, f"{new_avatar.name}晋升为修士了。", related_avatars=[new_avatar.id])
@@ -127,11 +131,12 @@ class Simulator:
- 触发奇遇(非动作)
"""
events = []
for avatar in self.world.avatar_manager.avatars.values():
living_avatars = self.world.avatar_manager.get_living_avatars()
for avatar in living_avatars:
avatar.update_time_effect()
# 使用 gather 并行触发奇遇
tasks = [try_trigger_fortune(avatar) for avatar in self.world.avatar_manager.avatars.values()]
tasks = [try_trigger_fortune(avatar) for avatar in living_avatars]
results = await asyncio.gather(*tasks)
for res in results:
if res:
@@ -146,7 +151,8 @@ class Simulator:
from src.classes.nickname import process_avatar_nickname
# 并发执行
tasks = [process_avatar_nickname(avatar) for avatar in self.world.avatar_manager.avatars.values()]
living_avatars = self.world.avatar_manager.get_living_avatars()
tasks = [process_avatar_nickname(avatar) for avatar in living_avatars]
results = await asyncio.gather(*tasks)
events = [e for e in results if e]
@@ -158,7 +164,8 @@ class Simulator:
检查角色是否需要生成/更新长期目标
"""
# 并发执行
tasks = [process_avatar_long_term_objective(avatar) for avatar in self.world.avatar_manager.avatars.values()]
living_avatars = self.world.avatar_manager.get_living_avatars()
tasks = [process_avatar_long_term_objective(avatar) for avatar in living_avatars]
results = await asyncio.gather(*tasks)
events = [e for e in results if e]

View File

@@ -34,8 +34,8 @@ social:
minor_event_context_num: 10 # 小事(短期记忆)展示数量
nickname:
major_event_threshold: 3 # 获得绰号需要的长期事件数量
minor_event_threshold: 20 # 获得绰号需要的短期事件数量
major_event_threshold: 5 # 获得绰号需要的长期事件数量
minor_event_threshold: 25 # 获得绰号需要的短期事件数量
save:
max_events_to_save: 1000

View File

@@ -11,8 +11,12 @@ const emit = defineEmits<{
(e: 'avatarSelected', payload: { type: 'avatar'; id: string; name?: string }): void
}>()
const visibleAvatars = computed(() => {
return worldStore.avatarList.filter(a => !a.is_dead)
})
const avatarOffsets = computed(() => {
return calculateVisualOffsets(worldStore.avatarList)
return calculateVisualOffsets(visibleAvatars.value)
})
function handleAvatarSelect(payload: { type: 'avatar'; id: string; name?: string }) {
@@ -23,7 +27,7 @@ function handleAvatarSelect(payload: { type: 'avatar'; id: string; name?: string
<template>
<container sortable-children>
<AnimatedAvatar
v-for="avatar in worldStore.avatarList"
v-for="avatar in visibleAvatars"
:key="avatar.id"
:avatar="avatar"
:tile-size="TILE_SIZE"

View File

@@ -67,10 +67,13 @@ async function handleClearObjective() {
/>
<!-- Actions Bar -->
<div class="actions-bar">
<div class="actions-bar" v-if="!data.is_dead">
<button class="btn primary" @click="showObjectiveModal = true">设定目标</button>
<button class="btn" @click="handleClearObjective">清空目标</button>
</div>
<div class="dead-banner" v-else>
已故 ({{ data.death_info?.reason || '未知原因' }})
</div>
<div class="content-scroll">
<!-- Stats Grid -->
@@ -212,6 +215,17 @@ async function handleClearObjective() {
margin-bottom: 12px;
}
.dead-banner {
background: #4a1a1a;
color: #ffaaaa;
padding: 8px;
border-radius: 4px;
text-align: center;
font-size: 13px;
margin-bottom: 12px;
border: 1px solid #7a2a2a;
}
.content-scroll {
flex: 1;
overflow-y: auto;

View File

@@ -9,7 +9,10 @@ const eventListRef = ref<HTMLElement | null>(null)
const filterOptions = computed(() => [
{ label: '所有人', value: 'all' },
...worldStore.avatarList.map(avatar => ({ label: avatar.name ?? avatar.id, value: avatar.id }))
...worldStore.avatarList.map(avatar => ({
label: (avatar.name ?? avatar.id) + (avatar.is_dead ? ' (已故)' : ''),
value: avatar.id
}))
])
const filteredEvents = computed(() => {

View File

@@ -117,32 +117,17 @@ export const useWorldStore = defineStore('world', () => {
setTime(payload.year, payload.month);
// 检查并处理死亡事件,移除已死亡的角色
if (payload.events && Array.isArray(payload.events)) {
const deathEvents = (payload.events as any[]).filter((e: any) => {
const c = e.content || '';
return c.includes('身亡') || c.includes('老死');
});
if (deathEvents.length > 0) {
const next = new Map(avatars.value);
let changed = false;
for (const de of deathEvents) {
if (de.related_avatar_ids && Array.isArray(de.related_avatar_ids)) {
for (const id of de.related_avatar_ids) {
if (next.has(id)) {
next.delete(id);
changed = true;
}
}
}
}
if (changed) {
avatars.value = next;
}
}
}
// if (payload.events && Array.isArray(payload.events)) {
// const deathEvents = (payload.events as any[]).filter((e: any) => {
// const c = e.content || '';
// return c.includes('身亡') || c.includes('老死');
// });
//
// if (deathEvents.length > 0) {
// // 旧逻辑:主动删除死人。现在改为软删除,后端会在 avatars 更新中推送 is_dead 状态,
// // 所以这里不再需要主动操作。前端展示层根据 is_dead 决定是否隐藏。
// }
// }
if (payload.avatars) updateAvatars(payload.avatars);
if (payload.events) addEvents(payload.events);

View File

@@ -37,6 +37,7 @@ export interface AvatarSummary extends EntityBase, Coordinates {
action?: string;
gender?: string;
pic_id?: number;
is_dead?: boolean;
}
export interface AvatarDetail extends EntityBase {
@@ -46,6 +47,12 @@ export interface AvatarDetail extends EntityBase {
lifespan: number;
nickname?: string;
appearance: string; // 外貌描述
is_dead?: boolean;
death_info?: {
time: number;
reason: string;
location: [number, number];
};
// 修行状态
realm: string;