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)
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,10 +16,17 @@ 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:
"""记录一件流出的兵器"""
@@ -34,17 +42,68 @@ class CirculationManager:
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 = ""

View File

@@ -19,6 +19,7 @@ from src.classes.technique import get_technique_by_sect, attribute_to_root, Tech
from src.classes.weapon import Weapon, weapons_by_id, weapons_by_name
from src.classes.auxiliary import Auxiliary, auxiliaries_by_id, auxiliaries_by_name
from src.classes.persona import Persona, personas_by_id, personas_by_name
from src.classes.magic_stone import MagicStone
# —— 参数常量(便于调参)——
@@ -437,6 +438,7 @@ class AvatarFactory:
sect=plan.sect,
)
avatar.magic_stone = MagicStone(50)
avatar.tile = world.map.get_tile(avatar.pos_x, avatar.pos_y)
SectRankAssigner.assign_one(avatar, world)
@@ -550,6 +552,7 @@ class AvatarFactory:
sect=sect,
)
avatar.magic_stone = MagicStone(50)
avatar.tile = world.map.get_tile(x, y)
if sect is not None:

View File

@@ -261,6 +261,13 @@ class Simulator:
events = [e for e in results if e]
return events
async def _phase_process_gatherings(self):
"""
Gathering 结算阶段:
检查并执行注册的多人聚集事件(如拍卖会、大比等)。
"""
return await self.world.gathering_manager.check_and_run_all(self.world)
def _phase_update_celestial_phenomenon(self):
"""
更新天地灵机:
@@ -393,48 +400,60 @@ class Simulator:
"""
events = [] # list of Event
# 0. 感知与认知更新阶段(包括自动占据洞府)
# 1. 感知与认知更新阶段(包括自动占据洞府)
# 在思考和决策之前,先让角色感知世界
self._phase_update_perception_and_knowledge()
# 0.5 长期目标思考阶段(在决策之前)
# 2. 长期目标思考阶段(在决策之前)
events.extend(await self._phase_long_term_objective_thinking())
# 1. 决策阶段
# 3. Gathering 结算阶段
events.extend(await self._phase_process_gatherings())
# 4. 决策阶段
await self._phase_decide_actions()
# 2. 提交阶段
# 5. 提交阶段
events.extend(self._phase_commit_next_plans())
# 3. 执行阶段
# 6. 执行阶段
events.extend(await self._phase_execute_actions())
# 4. 关系演化阶段
# 7. 关系演化阶段
events.extend(await self._phase_evolve_relations())
# 5. 结算死亡
# 8. 结算死亡
events.extend(self._phase_resolve_death())
# 6. 年龄与新生
# 9. 年龄与新生
events.extend(self._phase_update_age_and_birth())
# 7. 被动结算(时间效果+奇遇)
# 10. 被动结算(时间效果+奇遇)
events.extend(await self._phase_passive_effects())
# 8. 绰号生成
# 11. 绰号生成
events.extend(await self._phase_nickname_generation())
# 9. 更新天地灵机
# 12. 更新天地灵机
events.extend(self._phase_update_celestial_phenomenon())
# 10. 日志
# 13. 日志
# 去重:基于 ID 去重防止同一个事件对象或相同ID的事件被多次添加
# 常见情况Gathering 既返回了事件,又将其加入了 Avatar 的 pending_events
unique_events = {}
for e in events:
if e.id not in unique_events:
unique_events[e.id] = e
# 保持原有顺序Python 3.7+ dict 保持插入序)
events = list(unique_events.values())
# 统一写入事件管理器
if hasattr(self.world, "event_manager") and self.world.event_manager is not None:
for e in events:
self.world.event_manager.add_event(e)
self._phase_log_events(events)
# 11. 时间推进
# 14. 时间推进
self.world.month_stamp = self.world.month_stamp + 1

View File

@@ -27,6 +27,8 @@ game:
npc_awakening_rate_per_month: 0.01
fortune_probability: 0.005
misfortune_probability: 0.005
gathering:
auction_trigger_count: 10
df:
ids_separator: ";"

View File

@@ -0,0 +1,21 @@
你是一个价值评估者这是一个仙侠世界你负责来评估一些物品对于一些NPC的价值。
你需要进行决策的NPC的dict[AvatarName, info]为:
{avatar_infos}
需要评价需求的物品为:
{items}
注意只返回json格式的结果。
格式为:
{{
"{{avatar_name_1}}": {{
{{item_id_1}}: int, # 1~5
{{item_id_2}}: int,
...
}}, ...
}}
要求;
1. 所有的角色要在这里评估所有的物品也就是最后返回的是num_avatar * num_item。都必须有一个估计。
2. 分析每一个角色的情况,根据其情况返回需求程度。比如快老死的人对延寿丹药就是最需要的
3. 需求从1到51是完全不需要5是完全需要。

View File

@@ -0,0 +1,27 @@
你是一个小说家这是一个仙侠世界你需要这个修仙界中某一个集体事件选取一个有趣的切入点或侧面创作一段300~800字的小故事。
世界背景:
{world_info}
事件设定:
{gathering_info}
events
{events}
事件细节:
{details}
写作风格提示:{style}
额外主题提示:{story_prompt}
注意:
1. 不要试图面面俱到地描写所有事件,请**自行筛选**其中最有趣、最有戏剧性的部分进行扩写。
2. 侧重描写角色之间的互动、心理博弈、竞争或合作。
3. 重点描写竞价过程中的紧张气氛。
只返回json格式的结果格式为
{{
"thinking": ..., // 简单思考故事剧情
"story": "" // 第三人称的故事正文,仙侠语言风格
}}

View File

@@ -24,7 +24,8 @@ def target_avatar(base_world):
age=Age(20, Realm.Qi_Refinement),
gender=Gender.FEMALE,
pos_x=0,
pos_y=0
pos_y=0,
personas=[], # 避免随机特质影响价格测试
)
@pytest.fixture
@@ -159,7 +160,7 @@ class TestGiftAction:
dummy_avatar.weapon = new_weapon
old_weapon = create_test_weapon("旧铁剑", Realm.Qi_Refinement, weapon_id=999)
old_weapon.price = 100
# old_weapon.price = 100 # Prices 系统接管后,价格由 Realm 决定 (练气期=150),不再手动指定
target_avatar.weapon = old_weapon
target_avatar.magic_stone = 0
@@ -169,8 +170,8 @@ class TestGiftAction:
gift_action._settle_feedback(target_avatar, "Accept")
assert target_avatar.weapon == new_weapon
# 50% refund
assert target_avatar.magic_stone == 50
# 练气期武器基准价 150卖出倍率 1.0 (无特质加成) -> 150
assert target_avatar.magic_stone == 150
# --- 4. 上下文与描述 ---

425
tests/test_auction.py Normal file
View File

@@ -0,0 +1,425 @@
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from src.classes.gathering.auction import Auction
from src.classes.item import Item
from src.classes.weapon import Weapon
from src.classes.auxiliary import Auxiliary
from src.classes.prices import prices
from src.utils.config import CONFIG
# Monkeypatch removed as Weapon/Auxiliary now have __hash__ implemented
@pytest.mark.asyncio
async def test_auction_is_start(base_world, mock_item_data):
auction = Auction()
weapon = mock_item_data["obj_weapon"]
# 初始状态sold_item_count 为 0
# 清空 circulation
base_world.circulation.sold_weapons = []
base_world.circulation.sold_auxiliaries = []
base_world.circulation.sold_elixirs = []
# 设置阈值
CONFIG.game.gathering.auction_trigger_count = 5
assert auction.is_start(base_world) is False
# 增加物品数量达到阈值
for _ in range(5):
base_world.circulation.add_weapon(weapon)
assert auction.is_start(base_world) is True
def test_calculate_bid(dummy_avatar, mock_item_data):
auction = Auction()
item = mock_item_data["obj_weapon"]
base_price = prices.get_price(item)
# Case 1: 需求低 (<=1) -> 出价 0
assert auction._calculate_bid(item, 1, 1000) == 0
# Case 2: 需求 2 (捡漏 0.8)
expected_price = int(base_price * 0.8)
assert auction._calculate_bid(item, 2, 100000) == expected_price
# Case 3: 余额不足 -> 出价 = 余额
avatar_money = 10
bid = auction._calculate_bid(item, 3, avatar_money) # need 3 is 1.5x, definitely > 10
assert bid == avatar_money
# Case 4: 需求 5 (梭哈) -> 出价 = 余额
assert auction._calculate_bid(item, 5, 5000) == 5000
def test_resolve_auctions_basic(dummy_avatar, mock_item_data):
"""测试基本的竞价结算逻辑(单物品)"""
auction = Auction()
item = mock_item_data["obj_weapon"]
avatar1 = dummy_avatar
avatar1.magic_stone = 1000
avatar1.name = "A1"
# 创建第二个角色
avatar2 = MagicMock()
avatar2.magic_stone = 1000
avatar2.name = "A2"
avatar2.__hash__ = MagicMock(return_value=123) # Make it hashable for dict keys
# 模拟需求字典
needs = {
item: {
avatar1: 4, # High need
avatar2: 2 # Low need
}
}
# Mock prices
with patch("src.classes.prices.prices.get_price", return_value=100):
deal_results, unsold, willing = auction.resolve_auctions(needs)
# 验证结果
assert item in deal_results
winner, price = deal_results[item]
# A1 出价: 100 * 3.0 = 300
# A2 出价: 100 * 0.8 = 80
# 成交价应为第二高价(80) + 1 = 81
assert winner == avatar1
assert price == 81
assert not unsold
def test_resolve_auctions_asset_protection(dummy_avatar, mock_item_data):
"""测试资产穿透保护:同一个角色竞拍多个物品"""
auction = Auction()
item1 = mock_item_data["obj_weapon"] # 贵
item2 = mock_item_data["obj_material"] # 便宜
avatar = dummy_avatar
avatar.magic_stone = 100 # 总共只有 100
needs = {
item1: {avatar: 5}, # 梭哈 item1
item2: {avatar: 5} # 梭哈 item2
}
# Mock prices: item1=80, item2=50
# item1 应该先结算(价值高),因为是梭哈(need=5)出价100。
# 如果只有一人竞拍,成交价 = max(1, 100 * 0.6) = 60。
# 剩余余额 = 100 - 60 = 40。
# item2 结算时,余额只有 40虽然 need=5但出价只能是 40。
# item2 成交价 = max(1, 40 * 0.6) = 24。
def get_price_side_effect(item):
if item == item1: return 80
return 50
with patch("src.classes.prices.prices.get_price", side_effect=get_price_side_effect):
deal_results, unsold, willing = auction.resolve_auctions(needs)
# 验证 item1
assert item1 in deal_results
winner1, price1 = deal_results[item1]
assert winner1 == avatar
assert price1 == 60 # 100 * 0.6
# 验证 item2
assert item2 in deal_results
winner2, price2 = deal_results[item2]
assert winner2 == avatar
# 此时余额只剩 40出价 40成交价 40 * 0.6 = 24
assert price2 == 24
# 总花费 84 <= 100保护成功
assert price1 + price2 <= 100
def test_resolve_auctions_unsold(mock_item_data):
"""测试流拍"""
auction = Auction()
item = mock_item_data["obj_weapon"]
# 空需求或者需求都很低导致不出价
needs = {
item: {}
}
with patch("src.classes.prices.prices.get_price", return_value=100):
deal_results, unsold, willing = auction.resolve_auctions(needs)
assert item not in deal_results
assert item in unsold
@pytest.mark.asyncio
async def test_execute_flow(base_world, dummy_avatar, mock_item_data):
"""测试完整的 execute 流程,包括物品交易和销毁"""
auction = Auction()
item_sold = mock_item_data["obj_weapon"]
item_unsold = mock_item_data["obj_auxiliary"] # 使用 Auxiliary 代替 Material
# 设置环境
# 将物品加入 circulation 以便测试移除逻辑
base_world.circulation.sold_weapons = [item_sold]
base_world.circulation.sold_auxiliaries = [item_unsold]
# 设置 Avatar
dummy_avatar.magic_stone = 1000
dummy_avatar.weapon = None # 确保没有武器
dummy_avatar.auxiliary = None
# 确保 avatar 在 avatar_manager 中
base_world.avatar_manager.avatars[dummy_avatar.id] = dummy_avatar
# Mock methods
# 1. get_related_avatars
auction.get_related_avatars = MagicMock(return_value=[dummy_avatar.id])
# 2. get_needs (Async) -> 让 item_sold 有人买item_unsold 没人买
async def mock_get_needs(*args, **kwargs):
return {
item_sold: {dummy_avatar: 4}, # High need
item_unsold: {dummy_avatar: 1} # No need
}
auction.get_needs = mock_get_needs
# 3. Mock StoryTeller to avoid LLM
with patch("src.classes.story_teller.StoryTeller.tell_gathering_story", new_callable=AsyncMock) as mock_story:
mock_story.return_value = "拍卖会故事..."
# 4. Mock prices
with patch("src.classes.prices.prices.get_price", return_value=100):
events = await auction.execute(base_world)
# 验证结果
# 1. 物品去向
# item_sold 应该被 dummy_avatar 装备
assert dummy_avatar.weapon == item_sold
# item_sold 应该不在 circulation 中
assert item_sold not in base_world.circulation.sold_weapons
# item_unsold 应该被销毁 (不在 circulation 中,也不在 avatar 背包/装备 中)
assert item_unsold not in base_world.circulation.sold_auxiliaries
assert dummy_avatar.auxiliary != item_unsold
# 2. 资金扣除
# Base price 100, Need 4 (3.0x) -> Bid 300
# Single bidder -> Deal 300 * 0.6 = 180
# Balance 1000 - 180 = 820
assert dummy_avatar.magic_stone == 820
# 3. 事件生成
assert len(events) > 0
# 应该包含 story event
assert any(e.content == "拍卖会故事..." for e in events)
def test_items_are_hashable():
"""测试物品类是否可哈希(用作字典键)"""
from src.classes.weapon import Weapon
from src.classes.weapon_type import WeaponType
from src.classes.auxiliary import Auxiliary
from src.classes.elixir import Elixir, ElixirType
from src.classes.cultivation import Realm
# Weapon
w = Weapon(
id=1,
name="TestSword",
weapon_type=WeaponType.SWORD,
realm=Realm.Qi_Refinement,
desc="Test",
special_data={"a": 1} # mutable field
)
s = set()
s.add(w)
assert w in s
d = {w: 1}
assert d[w] == 1
# Auxiliary
a = Auxiliary(
id=2,
name="TestAux",
realm=Realm.Qi_Refinement,
desc="Test",
special_data={"b": 2} # mutable field
)
s = set()
s.add(a)
assert a in s
# Elixir
e = Elixir(
id=3,
name="TestElixir",
realm=Realm.Qi_Refinement,
type=ElixirType.Heal,
desc="Test",
price=100
)
s = set()
s.add(e)
assert e in s
def test_resolve_auctions_tie_breaking(dummy_avatar, mock_item_data):
"""测试出价相同时的判定(稳定性)"""
auction = Auction()
item = mock_item_data["obj_weapon"]
# 两个角色,需求相同,资金充足 -> 理论上出价相同
avatar1 = dummy_avatar
avatar1.magic_stone = 1000
avatar1.name = "A1"
avatar2 = MagicMock()
avatar2.magic_stone = 1000
avatar2.name = "A2"
avatar2.__hash__ = MagicMock(return_value=12345)
# 手动构建 needs 字典,控制 key 的顺序
# 情况1: A1 在前
needs1 = {
item: {avatar1: 5, avatar2: 5}
}
with patch("src.classes.prices.prices.get_price", return_value=100):
deal_results1, _, _ = auction.resolve_auctions(needs1)
winner1, _ = deal_results1[item]
# 如果是稳定排序,且 bid 相等应该保持顺序winner 是 A1
assert winner1 == avatar1
# 情况2: A2 在前
needs2 = {
item: {avatar2: 5, avatar1: 5}
}
with patch("src.classes.prices.prices.get_price", return_value=100):
deal_results2, _, _ = auction.resolve_auctions(needs2)
winner2, _ = deal_results2[item]
# winner 应该是 A2
assert winner2 == avatar2
def test_resolve_auctions_no_refund_consideration(dummy_avatar, mock_item_data):
"""测试拍卖结算时不考虑后续装备出售的退款(防止透支)"""
auction = Auction()
item1 = mock_item_data["obj_weapon"] # 贵, 先结算
item2 = mock_item_data["obj_elixir"] # 便宜, 后结算
avatar = dummy_avatar
avatar.magic_stone = 100
# 假设 avatar 身上有装备,卖出可得 50
old_weapon = MagicMock()
avatar.weapon = old_weapon
# 但 resolve_auctions 只看 snapshot不看装备退款
needs = {
item1: {avatar: 5}, # 梭哈 item1, cost 100
item2: {avatar: 5} # 梭哈 item2
}
# Mock prices: item1=80, item2=50
# item1 price 80, need 5 -> bid 100 (balance). Deal: 100*0.6 = 60.
# item2 price 50. Remaining balance 100-60=40.
# need 5 -> bid 40 (balance). Deal: 40*0.6 = 24.
# If refund (50) was considered, balance would be 40+50=90.
# Bid 90 -> Deal 54.
def get_price_side_effect(item):
if item == item1: return 80
return 50
with patch("src.classes.prices.prices.get_price", side_effect=get_price_side_effect):
deal_results, _, _ = auction.resolve_auctions(needs)
# item1 应该成交,消耗 60 (100 * 0.6)
assert deal_results[item1][0] == avatar
assert deal_results[item1][1] == 60
# item2 应该成交,消耗 24 (40 * 0.6)
# 证明使用了 40 的余额,而不是 90 (如果包含退款)
assert item2 in deal_results
assert deal_results[item2][1] == 24
# 总消耗 84 <= 100
assert deal_results[item1][1] + deal_results[item2][1] <= 100
@pytest.mark.asyncio
async def test_execute_item_types(base_world, dummy_avatar, mock_item_data):
"""测试不同类型物品的执行逻辑 (Elixir)"""
auction = Auction()
elixir = mock_item_data["obj_elixir"]
dummy_avatar.magic_stone = 1000
base_world.circulation.sold_elixirs = [elixir]
# Register avatar
base_world.avatar_manager.avatars[dummy_avatar.id] = dummy_avatar
# Mock resolve_auctions
auction.resolve_auctions = MagicMock(return_value=(
{elixir: (dummy_avatar, 100)},
[],
{}
))
# Mock dependencies
auction.get_related_avatars = MagicMock(return_value=[dummy_avatar.id])
auction.get_needs = AsyncMock(return_value={}) # ignored by mocked resolve
auction._generate_deal_events = MagicMock(return_value=[])
auction._generate_rivalry_events = MagicMock(return_value=[])
auction._generate_story = AsyncMock(return_value=[])
# Mock circulation remove
base_world.circulation.remove_item = MagicMock()
# Mock consume_elixir
dummy_avatar.consume_elixir = MagicMock()
# Ensure items are "in" circulation logic (count > 0)
# Circulation.sold_item_count is a property, depends on lists.
# We set sold_elixirs above, so it should be > 0.
await auction.execute(base_world)
# Verify consume_elixir called
dummy_avatar.consume_elixir.assert_called_once_with(elixir)
# Verify remove_item called
base_world.circulation.remove_item.assert_called_once_with(elixir)
@pytest.mark.asyncio
async def test_get_needs_parsing(base_world, dummy_avatar, mock_item_data):
"""测试 get_needs 的 LLM 结果解析逻辑"""
auction = Auction()
item = mock_item_data["obj_weapon"]
# Mock circulation
base_world.circulation.sold_weapons = [item]
# Mock LLM response
mock_response = {
dummy_avatar.name: {
str(item.id): 5 # High need
}
}
with patch("src.classes.gathering.auction.call_llm_with_template", new_callable=AsyncMock) as mock_llm:
mock_llm.return_value = mock_response
needs = await auction.get_needs(base_world, [dummy_avatar])
assert item in needs
assert needs[item][dummy_avatar] == 5
# Test filtering of low needs (<=1)
mock_response_low = {
dummy_avatar.name: {
str(item.id): 1
}
}
with patch("src.classes.gathering.auction.call_llm_with_template", new_callable=AsyncMock) as mock_llm:
mock_llm.return_value = mock_response_low
needs = await auction.get_needs(base_world, [dummy_avatar])
# Should be empty because score 1 is filtered
assert item not in needs or not needs.get(item)