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

@@ -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
@@ -158,8 +159,8 @@ class TestGiftAction:
new_weapon = mock_item_data["obj_weapon"]
dummy_avatar.weapon = new_weapon
old_weapon = create_test_weapon("旧铁剑", Realm.Qi_Refinement, weapon_id=999)
old_weapon.price = 100
old_weapon = create_test_weapon("旧铁剑", Realm.Qi_Refinement, weapon_id=999)
# 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)