- Market tab now calls GET /grandexchange/orders (all public orders) instead of /my/grandexchange/orders (own orders only), fixing the empty exchange issue - Fix capture_prices reading "type" field instead of wrong "order" field - Add proper pagination to all GE queries via _get_paginated - Separate My Orders (active own orders) from Trade History (transaction log) - Add GEHistoryEntry type matching GeOrderHistorySchema (order_id, seller, buyer, sold_at) - Add /api/exchange/my-orders and /api/exchange/sell-history endpoints - Exchange page now has 4 tabs: Market, My Orders, Trade History, Price History
304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""Tests for the CraftingStrategy state machine."""
|
|
|
|
import pytest
|
|
|
|
from app.engine.strategies.base import ActionType
|
|
from app.engine.strategies.crafting import CraftingStrategy
|
|
from app.schemas.game import CraftItem, CraftSchema, EffectSchema, InventorySlot, ItemSchema
|
|
|
|
|
|
def _make_craftable_item(
|
|
code: str = "iron_sword",
|
|
skill: str = "weaponcrafting",
|
|
level: int = 5,
|
|
materials: list[tuple[str, int]] | None = None,
|
|
) -> ItemSchema:
|
|
"""Helper to build an ItemSchema with a crafting recipe."""
|
|
if materials is None:
|
|
materials = [("iron_ore", 5), ("wood", 2)]
|
|
return ItemSchema(
|
|
name=code.replace("_", " ").title(),
|
|
code=code,
|
|
level=level,
|
|
type="weapon",
|
|
craft=CraftSchema(
|
|
skill=skill,
|
|
level=level,
|
|
items=[CraftItem(code=c, quantity=q) for c, q in materials],
|
|
),
|
|
)
|
|
|
|
|
|
class TestCraftingStrategyInitialization:
|
|
"""Tests for CraftingStrategy creation and recipe resolution."""
|
|
|
|
def test_initial_state(self, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf)
|
|
assert strategy.get_state() == "check_materials"
|
|
|
|
def test_recipe_resolved_from_items_data(self, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
item = _make_craftable_item()
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
|
|
assert strategy._recipe_resolved is True
|
|
assert len(strategy._recipe) == 2
|
|
assert strategy._craft_skill == "weaponcrafting"
|
|
|
|
def test_recipe_not_resolved_without_data(self, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf)
|
|
|
|
assert strategy._recipe_resolved is False
|
|
|
|
def test_set_items_data_resolves_recipe(self, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf)
|
|
item = _make_craftable_item()
|
|
strategy.set_items_data([item])
|
|
|
|
assert strategy._recipe_resolved is True
|
|
|
|
def test_set_items_data_no_double_resolve(self, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
item = _make_craftable_item()
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
# Calling again should not re-resolve
|
|
strategy._craft_skill = "overwritten"
|
|
strategy.set_items_data([item])
|
|
assert strategy._craft_skill == "overwritten"
|
|
|
|
|
|
class TestCraftingStrategyIdleWithoutRecipe:
|
|
"""Tests for behavior when recipe is not resolved."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_idle_when_recipe_not_resolved(self, make_character, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf)
|
|
char = make_character(x=0, y=0)
|
|
|
|
plan = await strategy.next_action(char)
|
|
assert plan.action_type == ActionType.IDLE
|
|
|
|
|
|
class TestCraftingStrategyMaterials:
|
|
"""Tests for material checking and withdrawal."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_move_to_bank_when_materials_missing(self, make_character, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([
|
|
(5, 5, "workshop", "weaponcrafting"),
|
|
(10, 0, "bank", "bank"),
|
|
])
|
|
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
char = make_character(x=0, y=0, inventory=[])
|
|
|
|
plan = await strategy.next_action(char)
|
|
assert plan.action_type == ActionType.MOVE
|
|
assert plan.params == {"x": 10, "y": 0}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_withdraw_materials_at_bank(self, make_character, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([
|
|
(5, 5, "workshop", "weaponcrafting"),
|
|
(10, 0, "bank", "bank"),
|
|
])
|
|
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
char = make_character(x=10, y=0, inventory=[])
|
|
|
|
plan = await strategy.next_action(char)
|
|
assert plan.action_type == ActionType.WITHDRAW_ITEM
|
|
assert plan.params["code"] == "iron_ore"
|
|
assert plan.params["quantity"] == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_materials_withdraw_remaining(self, make_character, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([
|
|
(5, 5, "workshop", "weaponcrafting"),
|
|
(10, 0, "bank", "bank"),
|
|
])
|
|
item = _make_craftable_item(materials=[("iron_ore", 5)])
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
# Character already has 2 iron_ore
|
|
char = make_character(
|
|
x=10, y=0,
|
|
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=2)],
|
|
)
|
|
|
|
plan = await strategy.next_action(char)
|
|
assert plan.action_type == ActionType.WITHDRAW_ITEM
|
|
assert plan.params["code"] == "iron_ore"
|
|
assert plan.params["quantity"] == 3 # Need 5, have 2, withdraw 3
|
|
|
|
|
|
class TestCraftingStrategyCrafting:
|
|
"""Tests for the crafting execution flow."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_move_to_workshop_with_all_materials(self, make_character, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([
|
|
(5, 5, "workshop", "weaponcrafting"),
|
|
(10, 0, "bank", "bank"),
|
|
])
|
|
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
char = make_character(
|
|
x=0, y=0,
|
|
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=3)],
|
|
)
|
|
|
|
plan = await strategy.next_action(char)
|
|
assert plan.action_type == ActionType.MOVE
|
|
assert plan.params == {"x": 5, "y": 5}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_craft_when_at_workshop_with_materials(self, make_character, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([
|
|
(5, 5, "workshop", "weaponcrafting"),
|
|
(10, 0, "bank", "bank"),
|
|
])
|
|
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
char = make_character(
|
|
x=5, y=5,
|
|
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=3)],
|
|
)
|
|
|
|
plan = await strategy.next_action(char)
|
|
assert plan.action_type == ActionType.CRAFT
|
|
assert plan.params["code"] == "iron_sword"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_after_crafting_quantity(self, make_character, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([
|
|
(5, 5, "workshop", "weaponcrafting"),
|
|
(10, 0, "bank", "bank"),
|
|
])
|
|
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
strategy = CraftingStrategy(
|
|
{"item_code": "iron_sword", "quantity": 1},
|
|
pf,
|
|
items_data=[item],
|
|
)
|
|
# Simulate having crafted enough
|
|
strategy._crafted_count = 1
|
|
|
|
char = make_character(x=5, y=5)
|
|
plan = await strategy.next_action(char)
|
|
assert plan.action_type == ActionType.COMPLETE
|
|
|
|
|
|
class TestCraftingStrategyRecycle:
|
|
"""Tests for recycle_excess behavior."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recycle_after_craft(self, make_character, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([
|
|
(5, 5, "workshop", "weaponcrafting"),
|
|
(10, 0, "bank", "bank"),
|
|
])
|
|
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
strategy = CraftingStrategy(
|
|
{"item_code": "iron_sword", "quantity": 10, "recycle_excess": True},
|
|
pf,
|
|
items_data=[item],
|
|
)
|
|
# Simulate: at workshop, just crafted, now checking result
|
|
strategy._state = strategy._state.__class__("check_result")
|
|
char = make_character(
|
|
x=5, y=5,
|
|
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=1)],
|
|
)
|
|
|
|
plan = await strategy.next_action(char)
|
|
assert plan.action_type == ActionType.RECYCLE
|
|
assert plan.params["code"] == "iron_sword"
|
|
|
|
|
|
class TestCraftingStrategyDeposit:
|
|
"""Tests for deposit behavior after crafting."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deposit_after_completing_all_crafts(self, make_character, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([
|
|
(5, 5, "workshop", "weaponcrafting"),
|
|
(10, 0, "bank", "bank"),
|
|
])
|
|
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
strategy = CraftingStrategy(
|
|
{"item_code": "iron_sword", "quantity": 1},
|
|
pf,
|
|
items_data=[item],
|
|
)
|
|
# Simulate: just crafted the last item
|
|
strategy._state = strategy._state.__class__("check_result")
|
|
strategy._crafted_count = 0 # Will be incremented in handler
|
|
|
|
char = make_character(
|
|
x=5, y=5,
|
|
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=1)],
|
|
)
|
|
|
|
plan = await strategy.next_action(char)
|
|
# After crafting 1/1, should move to bank to deposit
|
|
assert plan.action_type == ActionType.MOVE
|
|
assert plan.params == {"x": 10, "y": 0}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deposit_items_at_bank(self, make_character, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([
|
|
(5, 5, "workshop", "weaponcrafting"),
|
|
(10, 0, "bank", "bank"),
|
|
])
|
|
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
strategy = CraftingStrategy(
|
|
{"item_code": "iron_sword", "quantity": 1},
|
|
pf,
|
|
items_data=[item],
|
|
)
|
|
strategy._state = strategy._state.__class__("deposit")
|
|
strategy._crafted_count = 1
|
|
|
|
char = make_character(
|
|
x=10, y=0,
|
|
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=1)],
|
|
)
|
|
|
|
plan = await strategy.next_action(char)
|
|
assert plan.action_type == ActionType.DEPOSIT_ITEM
|
|
assert plan.params["code"] == "iron_sword"
|
|
|
|
|
|
class TestCraftingStrategyNoLocations:
|
|
"""Tests for missing map tiles."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_idle_when_no_bank(self, make_character, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([
|
|
(5, 5, "workshop", "weaponcrafting"),
|
|
])
|
|
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
char = make_character(x=0, y=0, inventory=[])
|
|
|
|
plan = await strategy.next_action(char)
|
|
assert plan.action_type == ActionType.IDLE
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_idle_when_no_workshop(self, make_character, pathfinder_with_maps):
|
|
pf = pathfinder_with_maps([
|
|
(10, 0, "bank", "bank"),
|
|
])
|
|
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
char = make_character(
|
|
x=0, y=0,
|
|
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=3)],
|
|
)
|
|
|
|
plan = await strategy.next_action(char)
|
|
assert plan.action_type == ActionType.IDLE
|