Feat/auction (#30)

Add gathering events, in which multiple avatars participate
Add auction event

Closes #24
This commit is contained in:
4thfever
2026-01-14 02:33:13 +08:00
committed by GitHub
parent 0d34b27fff
commit 63fc2f828e
19 changed files with 1219 additions and 27 deletions

View File

@@ -26,6 +26,9 @@ class Auxiliary(Item):
# 特殊属性(用于存储实例特定数据)
special_data: dict = field(default_factory=dict)
def __hash__(self):
return hash((self.id, self.name))
def get_info(self, detailed: bool = False) -> str:
"""获取信息"""
if detailed:

View File

@@ -95,7 +95,15 @@ class ActionMixin:
continue
params_for_can_start = filter_kwargs_for_callable(action.can_start, plan.params)
can_start, reason = action.can_start(**params_for_can_start)
try:
can_start, reason = action.can_start(**params_for_can_start)
except TypeError as e:
get_logger().logger.warning(
"动作启动失败: Avatar(name=%s) 动作 %s 参数校验异常: %s",
self.name, plan.action_name, e
)
continue
if not can_start:
# 记录不合法动作
logger = get_logger().logger

View File

@@ -162,6 +162,9 @@ class InventoryMixin:
"""
from src.classes.prices import prices
# 记录流转
self.world.circulation.add_elixir(elixir)
# 使用统一的卖出价格接口
total = prices.get_selling_price(elixir, self)
self.magic_stone = self.magic_stone + total

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
from typing import Dict, List, TYPE_CHECKING
from typing import Dict, List, TYPE_CHECKING, Any
import copy
if TYPE_CHECKING:
from src.classes.weapon import Weapon
from src.classes.auxiliary import Auxiliary
from src.classes.elixir import Elixir
class CirculationManager:
@@ -15,11 +16,18 @@ class CirculationManager:
"""
def __init__(self):
# 存储被卖出的法宝
# 存储被卖出的兵器
self.sold_weapons: List[Weapon] = []
# 存储被卖出的宝物
# 存储被卖出的辅助
self.sold_auxiliaries: List[Auxiliary] = []
# 存储被卖出的丹药
self.sold_elixirs: List[Elixir] = []
@property
def sold_item_count(self) -> int:
"""记录一共有多少个sold items"""
return len(self.sold_weapons) + len(self.sold_auxiliaries) + len(self.sold_elixirs)
def add_weapon(self, weapon: "Weapon") -> None:
"""记录一件流出的兵器"""
if weapon is None:
@@ -33,18 +41,69 @@ class CirculationManager:
if auxiliary is None:
return
self.sold_auxiliaries.append(auxiliary.instantiate())
def add_elixir(self, elixir: "Elixir") -> None:
"""记录一件流出的丹药"""
if elixir is None:
return
# 尝试实例化,如果失败(如未实现)则拷贝,或者直接存储
if hasattr(elixir, "instantiate"):
self.sold_elixirs.append(elixir.instantiate())
else:
self.sold_elixirs.append(copy.copy(elixir))
def add_item(self, item: Any) -> None:
"""
通用入口:记录一件流出的物品。
根据物品类型自动分发到对应的存储列表中。
设计此方法的目的是为了统一所有物品流出逻辑,避免各个业务模块手动判断类型。
"""
if item is None:
return
from src.classes.weapon import Weapon
from src.classes.auxiliary import Auxiliary
from src.classes.elixir import Elixir
if isinstance(item, Weapon):
self.add_weapon(item)
elif isinstance(item, Auxiliary):
self.add_auxiliary(item)
elif isinstance(item, Elixir):
self.add_elixir(item)
# 未来扩展其他类型...
def remove_item(self, item: Any) -> None:
"""
从流通池移除物品
"""
from src.classes.weapon import Weapon
from src.classes.auxiliary import Auxiliary
from src.classes.elixir import Elixir
if isinstance(item, Weapon):
if item in self.sold_weapons:
self.sold_weapons.remove(item)
elif isinstance(item, Auxiliary):
if item in self.sold_auxiliaries:
self.sold_auxiliaries.remove(item)
elif isinstance(item, Elixir):
if item in self.sold_elixirs:
self.sold_elixirs.remove(item)
def to_save_dict(self) -> dict:
"""序列化为字典以便存档"""
return {
"weapons": [self._item_to_dict(w) for w in self.sold_weapons],
"auxiliaries": [self._item_to_dict(a) for a in self.sold_auxiliaries]
"auxiliaries": [self._item_to_dict(a) for a in self.sold_auxiliaries],
"elixirs": [self._item_to_dict(e) for e in self.sold_elixirs]
}
def load_from_dict(self, data: dict) -> None:
"""从字典恢复数据"""
from src.classes.weapon import weapons_by_id
from src.classes.auxiliary import auxiliaries_by_id
from src.classes.elixir import elixirs_by_id
self.sold_weapons = []
for w_data in data.get("weapons", []):
@@ -62,6 +121,14 @@ class CirculationManager:
auxiliary.special_data = a_data.get("special_data", {})
self.sold_auxiliaries.append(auxiliary)
self.sold_elixirs = []
for e_data in data.get("elixirs", []):
e_id = e_data.get("id")
if e_id in elixirs_by_id:
elixir = copy.copy(elixirs_by_id[e_id])
elixir.special_data = e_data.get("special_data", {})
self.sold_elixirs.append(elixir)
def _item_to_dict(self, item) -> dict:
"""将物品对象转换为简略的存储格式"""
return {

View File

@@ -35,6 +35,9 @@ class Elixir(Item):
effects: Union[dict[str, object], list[dict[str, object]]] = field(default_factory=dict)
effect_desc: str = ""
def __hash__(self):
return hash((self.id, self.name))
def get_info(self, detailed: bool = False) -> str:
"""获取信息"""
if detailed:

View File

@@ -0,0 +1,2 @@
from .gathering import Gathering, GatheringManager
from .auction import Auction

View File

@@ -0,0 +1,482 @@
from typing import List, Dict, TYPE_CHECKING
import asyncio
from src.classes.gathering.gathering import Gathering, register_gathering
from src.classes.event import Event
from src.utils.config import CONFIG
from src.utils.llm.client import call_llm_with_template
if TYPE_CHECKING:
from src.classes.world import World
from src.classes.avatar import Avatar
from src.classes.item import Item
@register_gathering
class Auction(Gathering):
"""
拍卖会事件
"""
def is_start(self, world: "World") -> bool:
"""
检测拍卖会是否开始
条件:后台积攒的 sold_item_count 到达配置阈值
"""
threshold = CONFIG.game.gathering.auction_trigger_count
return world.circulation.sold_item_count >= threshold
def get_related_avatars(self, world: "World") -> List[int]:
"""
所有存活的 avatar 都参与
"""
return [avatar.id for avatar in world.avatar_manager.get_living_avatars()]
def get_info(self, world: "World") -> str:
# TODO: Implement get_info
return "拍卖会正在举行..."
async def get_needs(self, world: "World", avatars: List["Avatar"]) -> Dict["Item", Dict["Avatar", int]]:
"""
获取所有参与角色对拍卖品的需程度
返回格式: Dict[Item, Dict[Avatar, int]]
"""
# 1. 准备拍卖品信息
items_info = []
# 收集流通管理器中的物品
circulation = world.circulation
all_items = []
all_items.extend(circulation.sold_weapons)
all_items.extend(circulation.sold_auxiliaries)
all_items.extend(circulation.sold_elixirs)
for item in all_items:
# 假设所有 item 都有 get_info 或类似方法
# 这里统一用 get_detailed_info 如果有的话,或者 str(item)
info = getattr(item, "get_detailed_info", lambda: str(item))()
# 补充 ID 以便 LLM 引用
items_info.append(f"ID: {item.id}, Info: {info}")
items_str = "\n".join(items_info)
# 2. 准备角色信息并分批处理
batch_size = 5
tasks = []
for i in range(0, len(avatars), batch_size):
batch_avatars = avatars[i : i + batch_size]
# 构建该批次的 avatar_infos 字符串
batch_infos = {}
for avatar in batch_avatars:
# 使用 avatar.get_info(detailed=True)
info = avatar.get_info(detailed=True)
batch_infos[avatar.name] = str(info)
# 构建模板参数
template_params = {
"avatar_infos": str(batch_infos),
"items": items_str
}
# 创建并发任务
task = call_llm_with_template(
template_path=CONFIG.paths.templates / "auction_need.txt",
infos=template_params
)
tasks.append(task)
# 3. 并发执行并收集结果
results = await asyncio.gather(*tasks)
# 4. 合并结果
final_needs = {}
for result in results:
if isinstance(result, dict):
final_needs.update(result)
# 5. 转换结构为 dict[Item, dict[Avatar, int]]
# 建立 name -> Avatar 映射
name_to_avatar = {av.name: av for av in avatars}
# 建立 id -> Item 映射
id_to_item = {str(item.id): item for item in all_items}
item_based_needs: Dict["Item", Dict["Avatar", int]] = {}
# 遍历 final_needs: {avatar_name: {item_id: score}}
for av_name, items_score_map in final_needs.items():
avatar = name_to_avatar.get(av_name)
if not avatar:
continue
for item_id, score in items_score_map.items():
item = id_to_item.get(str(item_id))
if not item:
continue
score_val = int(score)
# 只有需求 > 1 才记录可选优化1表示完全不需要
if score_val <= 1:
continue
if item not in item_based_needs:
item_based_needs[item] = {}
item_based_needs[item][avatar] = score_val
return item_based_needs
def _calculate_bid(self, item: "Item", need_level: int, current_balance: int) -> int:
"""
计算单次出价,根据当前余额动态调整
"""
from src.classes.prices import prices
if need_level <= 1:
return 0
# 策略:
# Need 2: min(money, base_price * 0.8) (捡漏)
# Need 3: min(money, base_price * 1.5) (略微溢价)
# Need 4: min(money, base_price * 3.0) (高倍溢价)
# Need 5: money (梭哈)
base_price = prices.get_price(item)
multipliers = {
2: 0.8,
3: 1.5,
4: 3.0,
}
if need_level >= 5:
return current_balance
multiplier = multipliers.get(need_level, 0.0)
calculated_price = int(base_price * multiplier)
# 最终出价不能超过当前余额
return min(current_balance, calculated_price)
def resolve_auctions(
self,
needs: Dict["Item", Dict["Avatar", int]]
) -> tuple[Dict["Item", tuple["Avatar", int]], List["Item"], Dict["Item", Dict["Avatar", int]]]:
"""
结算拍卖结果
Returns:
deal_results: 成交结果 {item: (winner, price)}
unsold_items: 流拍物品列表
all_willing_prices: 所有的出价记录 {item: {avatar: price}} (用于生成故事)
"""
from src.classes.prices import prices
deal_results = {}
unsold_items = []
all_willing_prices = {}
# 1. 建立角色资金快照
all_avatars = set()
for av_map in needs.values():
all_avatars.update(av_map.keys())
current_balances = {av: int(av.magic_stone) for av in all_avatars}
# 2. 物品排序:按价值从高到低结算,优先处理贵重物品
sorted_items = sorted(needs.keys(), key=lambda x: prices.get_price(x), reverse=True)
for item in sorted_items:
avatar_needs = needs[item]
bids = {}
# 计算该物品的所有有效出价
for avatar, need_val in avatar_needs.items():
balance = current_balances.get(avatar, 0)
if balance <= 0:
continue
bid = self._calculate_bid(item, need_val, balance)
if bid > 0:
bids[avatar] = bid
if bids:
all_willing_prices[item] = bids
# 判定流拍
if not bids:
unsold_items.append(item)
continue
# 判定赢家 (第二价格密封拍卖)
sorted_bids = sorted(bids.items(), key=lambda x: x[1], reverse=True)
winner, highest_bid = sorted_bids[0]
deal_price = 0
if len(sorted_bids) >= 2:
second_bid = sorted_bids[1][1]
deal_price = min(highest_bid, second_bid + 1)
else:
# 无竞争:底价成交 (60% bid)
deal_price = max(1, int(highest_bid * 0.6))
# 只有成交价 <= 余额时才有效(理论上 calculate_bid 已经保证了 bid <= balance
# 但为了逻辑闭环,且 bid >= deal_price所以 deal_price <= balance 必然成立)
# 更新状态
current_balances[winner] -= deal_price
deal_results[item] = (winner, deal_price)
return deal_results, unsold_items, all_willing_prices
def _generate_deal_events(
self,
world: "World",
deal_results: Dict["Item", tuple["Avatar", int]]
) -> List[Event]:
"""
生成成交事件
"""
events = []
month_stamp = world.month_stamp
for item, (winner, deal_price) in deal_results.items():
deal_event = Event(
month_stamp=month_stamp,
content=f"在拍卖会上,{winner.name}{deal_price} 灵石拍下了{item.name}",
related_avatars=[winner.id],
is_major=False
)
events.append(deal_event)
# to_sidebar=False: 不加入 _pending_events防止重复提交给 Simulator
# 但仍会执行关系计数的更新逻辑
winner.add_event(deal_event, to_sidebar=False)
return events
def _generate_rivalry_events(
self,
world: "World",
willing_prices: Dict["Item", Dict["Avatar", int]],
deal_results: Dict["Item", tuple["Avatar", int]]
) -> List[Event]:
"""
生成竞争(压一头)事件
"""
events = []
month_stamp = world.month_stamp
for item in deal_results.keys():
bids = willing_prices.get(item, {})
if len(bids) < 2:
continue
# 获取出价前两名
sorted_bids = sorted(bids.items(), key=lambda x: x[1], reverse=True)
winner_avatar = sorted_bids[0][0]
runner_up_avatar = sorted_bids[1][0]
# 生成压一头事件
rivalry_content = f"{item.name}的竞拍中,{winner_avatar.name}力压{runner_up_avatar.name}一头,最终将其收入囊中。"
rivalry_event = Event(
month_stamp=month_stamp,
content=rivalry_content,
related_avatars=[winner_avatar.id, runner_up_avatar.id],
is_major=False
)
events.append(rivalry_event)
# 给双方都添加这个互动事件 (to_sidebar=False 防止重复提交)
winner_avatar.add_event(rivalry_event, to_sidebar=False)
runner_up_avatar.add_event(rivalry_event, to_sidebar=False)
return events
async def _generate_story(
self,
world: "World",
deal_results: Dict["Item", tuple["Avatar", int]],
willing_prices: Dict["Item", Dict["Avatar", int]]
) -> List[Event]:
"""
生成故事 (StoryTeller)
将本次拍卖的所有重要信息(成交、竞争)汇总传给 LLM
让 LLM 自行选取切入点生成故事。
"""
events = []
# 1. 收集所有相关事件文本
interaction_lines = []
# 收集成交信息
for item, (winner, deal_price) in deal_results.items():
interaction_lines.append(f"成交:{winner.name}{deal_price}灵石拍下{item.name}")
# 收集竞争信息(压一头)
# 这里为了避免重复太琐碎,只记录竞争激烈(参与者>=2的情况
rivalry_avatars = set()
for item, bids in willing_prices.items():
if len(bids) < 2:
continue
sorted_bids = sorted(bids.items(), key=lambda x: x[1], reverse=True)
winner = sorted_bids[0][0]
runner_up = sorted_bids[1][0]
interaction_lines.append(f"竞争:在{item.name}的竞拍中,{winner.name}力压{runner_up.name}(出价{sorted_bids[1][1]})。")
rivalry_avatars.add(winner)
rivalry_avatars.add(runner_up)
if not interaction_lines:
return []
interaction_result = "\n".join(interaction_lines)
# 2. 收集相关 items 信息
# 只收集成交了的或者有竞争的物品
related_items = set(deal_results.keys())
for item in willing_prices:
if len(willing_prices[item]) >= 2:
related_items.add(item)
items_info_list = []
for item in related_items:
info = getattr(item, "get_detailed_info", lambda: str(item))()
items_info_list.append(f"物品:{item.name},介绍:{info}")
items_info_str = "\n".join(items_info_list)
# 3. 收集相关 avatars
# 主要是成交者和有明显竞争行为的
related_avatars = set()
for winner, _ in deal_results.values():
related_avatars.add(winner)
related_avatars.update(rivalry_avatars)
if not related_avatars:
return []
# 4. 调用 StoryTeller
from src.classes.story_teller import StoryTeller
# 准备模板参数
gathering_info = (
"事件类型:神秘拍卖会\n"
"场景设定:拍卖会发生在一处神秘空间,由一位面目模糊、气息深不可测的神秘人主持。"
)
# 构建 details (物品信息 + 角色信息)
# 物品信息
details_list = []
if items_info_str:
details_list.append("【涉及拍品信息】")
details_list.append(items_info_str)
# 角色信息
details_list.append("\n【相关角色信息】")
for av in related_avatars:
# 获取详细信息
info = av.get_info(detailed=True)
details_list.append(f"- {av.name}: {info}")
details_text = "\n".join(details_list)
story = await StoryTeller.tell_gathering_story(
gathering_info=gathering_info,
events_text=interaction_result,
details_text=details_text,
related_avatars=list(related_avatars),
prompt="选取其中最有趣的一个侧面或一次竞价进行描写,无需面面俱到。"
)
# 5. 生成并分发事件
story_event = Event(
month_stamp=world.month_stamp,
content=story,
related_avatars=[av.id for av in related_avatars],
is_major=True
)
events.append(story_event)
for av in related_avatars:
av.add_event(story_event, to_sidebar=False)
return events
async def execute(self, world: "World") -> List[Event]:
"""
执行拍卖会
"""
events = []
# 0. 检查是否有物品
# 只要 sold_item_count >= threshold 就已经保证有物品了,但为了安全再检查一次
if world.circulation.sold_item_count == 0:
return []
avatars = [world.avatar_manager.get_avatar(aid) for aid in self.get_related_avatars(world)]
# 过滤掉 None
avatars = [a for a in avatars if a]
if not avatars:
return []
# 1. 计算需求
needs = await self.get_needs(world, avatars)
# 2. 结算拍卖 (动态计算出价,处理资产穿透)
deal_results, unsold_items, willing_prices = self.resolve_auctions(needs)
# 3. 执行交易 (扣钱、给物品、移除 circulation)
from src.classes.weapon import Weapon
from src.classes.auxiliary import Auxiliary
from src.classes.elixir import Elixir
from src.classes.material import Material
from src.classes.prices import prices
# 处理成交物品
for item, (winner, price) in deal_results.items():
# 扣钱
winner.magic_stone -= price
# 移除 circulation (先移除,避免因为交换装备导致的添加逻辑混淆)
world.circulation.remove_item(item)
# 给物品
if isinstance(item, (Weapon, Auxiliary)):
# 装备并处理旧装备
# 特殊逻辑:拍卖会换下的旧装备直接销毁(折价回收但不再进入流通池),防止物品无限膨胀
if isinstance(item, Weapon):
old_equip = winner.weapon
if old_equip:
# 计算回收价
refund = prices.get_selling_price(old_equip, winner)
winner.magic_stone += refund
# 换装
winner.change_weapon(item)
elif isinstance(item, Auxiliary):
old_equip = winner.auxiliary
if old_equip:
refund = prices.get_selling_price(old_equip, winner)
winner.magic_stone += refund
# 换装
winner.change_auxiliary(item)
elif isinstance(item, Elixir):
# 丹药直接服用
winner.consume_elixir(item)
elif isinstance(item, Material):
# 材料放入背包
winner.add_material(item)
# 处理流拍物品:直接销毁(移出流通池)
for item in unsold_items:
world.circulation.remove_item(item)
# 5. 生成基础事件
deal_events = self._generate_deal_events(world, deal_results)
rivalry_events = self._generate_rivalry_events(world, willing_prices, deal_results)
events.extend(deal_events)
events.extend(rivalry_events)
# 6. 生成故事 (StoryTeller)
story_events = await self._generate_story(world, deal_results, willing_prices)
events.extend(story_events)
return events

View File

@@ -0,0 +1,74 @@
from abc import ABC, abstractmethod
from typing import List, Type, TYPE_CHECKING
from src.classes.event import Event
if TYPE_CHECKING:
from src.classes.world import World
class Gathering(ABC):
"""
多人聚集事件/场景的抽象基类。
用于处理如“拍卖会”、“宗门大比”、“秘境开启”等多角色参与的复杂事件。
"""
@abstractmethod
def is_start(self, world: "World") -> bool:
"""
检测该 Gathering 是否应该开始。
通常基于时间、地点或特定触发条件。
"""
pass
@abstractmethod
def get_related_avatars(self, world: "World") -> List[int]:
"""
获取参与该 Gathering 的角色 ID 列表。
"""
pass
@abstractmethod
def get_info(self, world: "World") -> str:
"""
获取 Gathering 的描述信息。
"""
pass
@abstractmethod
async def execute(self, world: "World") -> List[Event]:
"""
执行 Gathering 的具体逻辑。
备注:
因为 Gathering 被设计为瞬时结算(在一个 step 内完成),
所以不需要 finish 函数。所有的交互和结果都在 execute 中一次性处理完毕。
"""
pass
# Global registry for Gathering classes
GATHERING_REGISTRY: List[Type[Gathering]] = []
def register_gathering(cls: Type[Gathering]):
"""
装饰器:注册 Gathering 类
"""
GATHERING_REGISTRY.append(cls)
return cls
class GatheringManager:
def __init__(self):
# 实例化所有注册的 Gathering
self.gatherings: List[Gathering] = [cls() for cls in GATHERING_REGISTRY]
async def check_and_run_all(self, world: "World") -> List[Event]:
"""
检查所有 Gathering若满足条件则执行
"""
events = []
for gathering in self.gatherings:
if gathering.is_start(world):
# 执行 Gathering 逻辑
gathering_events = await gathering.execute(world)
if gathering_events:
events.extend(gathering_events)
return events

View File

@@ -231,11 +231,13 @@ class Gift(MutualAction):
old_item = target.auxiliary
target.auxiliary = new_equip
# 旧装备简单处理:折价变成灵石加给目标
# 旧装备处理:直接调用 sell_X 接口
# 这样既能获得灵石,也能自动触发 CirculationManager 记录流出物品
if old_item:
refund = int(getattr(old_item, "price", 0) * 0.5)
if refund > 0:
target.magic_stone += refund
if isinstance(old_item, Weapon):
target.sell_weapon(old_item)
elif isinstance(old_item, Auxiliary):
target.sell_auxiliary(old_item)
else:
# 素材:发起者移除 -> 目标添加

View File

@@ -41,6 +41,7 @@ class StoryTeller:
TEMPLATE_SINGLE_PATH = CONFIG.paths.templates / "story_single.txt"
TEMPLATE_DUAL_PATH = CONFIG.paths.templates / "story_dual.txt"
TEMPLATE_GATHERING_PATH = CONFIG.paths.templates / "story_gathering.txt"
@staticmethod
def _build_avatar_infos(*actors: "Avatar") -> Dict[str, dict]:
@@ -127,5 +128,48 @@ class StoryTeller:
return StoryTeller._make_fallback_story(event, res, infos["style"])
@staticmethod
async def tell_gathering_story(
gathering_info: str,
events_text: str,
details_text: str,
related_avatars: list["Avatar"],
prompt: str = ""
) -> str:
"""
生成聚会/拍卖会等多人事件的故事。
通用接口,适配 story_gathering.txt 模板。
Args:
gathering_info: 事件本身的设定信息(如地点、背景、规则等)
events_text: 发生的具体事件/交互记录
details_text: 详细信息(包括角色信息、物品信息等)
related_avatars: 参与的角色列表(主要用于获取世界背景信息)
prompt: 额外提示词
"""
if not related_avatars:
return events_text
# 使用第一个角色的世界信息
world_info = related_avatars[0].world.static_info
infos = {
"world_info": world_info,
"gathering_info": gathering_info,
"events": events_text,
"details": details_text,
"style": random.choice(story_styles),
"story_prompt": prompt
}
# 增加 token 上限以支持长故事
data = await call_llm_with_task_name("story_teller", StoryTeller.TEMPLATE_GATHERING_PATH, infos)
story = data.get("story", "").strip()
if story:
return story
return events_text
__all__ = ["StoryTeller"]

View File

@@ -30,6 +30,9 @@ class Weapon(Item):
# 特殊属性(如万魂幡的吞噬魂魄计数)
special_data: dict = field(default_factory=dict)
def __hash__(self):
return hash((self.id, self.name))
def get_info(self, detailed: bool = False) -> str:
"""获取信息"""
if detailed:

View File

@@ -7,6 +7,7 @@ from src.classes.calendar import Year, Month, MonthStamp
from src.classes.avatar_manager import AvatarManager
from src.classes.event_manager import EventManager
from src.classes.circulation import CirculationManager
from src.classes.gathering.gathering import GatheringManager
if TYPE_CHECKING:
from src.classes.avatar import Avatar
@@ -26,6 +27,8 @@ class World():
phenomenon_start_year: int = 0
# 出世物品流通管理器
circulation: CirculationManager = field(default_factory=CirculationManager)
# Gathering 管理器
gathering_manager: GatheringManager = field(default_factory=GatheringManager)
# 世界历史文本
history: str = ""