Fix Grand Exchange: use public browse endpoint, fix price capture, add proper tabs
- 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
This commit is contained in:
parent
10781c7987
commit
2484a40dbd
19 changed files with 2081 additions and 257 deletions
|
|
@ -30,13 +30,34 @@ def _get_exchange_service(request: Request) -> ExchangeService:
|
|||
|
||||
|
||||
@router.get("/orders")
|
||||
async def get_orders(request: Request) -> dict[str, Any]:
|
||||
"""Get all active Grand Exchange orders."""
|
||||
async def browse_orders(
|
||||
request: Request,
|
||||
code: str | None = Query(default=None, description="Filter by item code"),
|
||||
type: str | None = Query(default=None, description="Filter by order type (sell or buy)"),
|
||||
) -> dict[str, Any]:
|
||||
"""Browse all active Grand Exchange orders (public market data)."""
|
||||
client = _get_client(request)
|
||||
service = _get_exchange_service(request)
|
||||
|
||||
try:
|
||||
orders = await service.get_orders(client)
|
||||
orders = await service.browse_orders(client, code=code, order_type=type)
|
||||
except HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code,
|
||||
detail=f"Artifacts API error: {exc.response.text}",
|
||||
) from exc
|
||||
|
||||
return {"orders": orders}
|
||||
|
||||
|
||||
@router.get("/my-orders")
|
||||
async def get_my_orders(request: Request) -> dict[str, Any]:
|
||||
"""Get the authenticated account's own active GE orders."""
|
||||
client = _get_client(request)
|
||||
service = _get_exchange_service(request)
|
||||
|
||||
try:
|
||||
orders = await service.get_my_orders(client)
|
||||
except HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code,
|
||||
|
|
@ -48,7 +69,7 @@ async def get_orders(request: Request) -> dict[str, Any]:
|
|||
|
||||
@router.get("/history")
|
||||
async def get_history(request: Request) -> dict[str, Any]:
|
||||
"""Get Grand Exchange transaction history."""
|
||||
"""Get the authenticated account's GE transaction history."""
|
||||
client = _get_client(request)
|
||||
service = _get_exchange_service(request)
|
||||
|
||||
|
|
@ -63,13 +84,30 @@ async def get_history(request: Request) -> dict[str, Any]:
|
|||
return {"history": history}
|
||||
|
||||
|
||||
@router.get("/sell-history/{item_code}")
|
||||
async def get_sell_history(item_code: str, request: Request) -> dict[str, Any]:
|
||||
"""Get public sale history for a specific item (last 7 days from API)."""
|
||||
client = _get_client(request)
|
||||
service = _get_exchange_service(request)
|
||||
|
||||
try:
|
||||
history = await service.get_sell_history(client, item_code)
|
||||
except HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code,
|
||||
detail=f"Artifacts API error: {exc.response.text}",
|
||||
) from exc
|
||||
|
||||
return {"item_code": item_code, "history": history}
|
||||
|
||||
|
||||
@router.get("/prices/{item_code}")
|
||||
async def get_price_history(
|
||||
item_code: str,
|
||||
request: Request,
|
||||
days: int = Query(default=7, ge=1, le=90, description="Number of days of history"),
|
||||
) -> dict[str, Any]:
|
||||
"""Get price history for a specific item."""
|
||||
"""Get locally captured price history for a specific item."""
|
||||
service = _get_exchange_service(request)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
|
|
|
|||
|
|
@ -3,76 +3,63 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from httpx import HTTPStatusError
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||
|
||||
|
||||
def _get_client(request: Request) -> ArtifactsClient:
|
||||
return request.app.state.artifacts_client
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_character_logs(
|
||||
request: Request,
|
||||
async def get_logs(
|
||||
character: str = Query(default="", description="Character name to filter logs"),
|
||||
limit: int = Query(default=50, ge=1, le=200, description="Max entries to return"),
|
||||
) -> dict[str, Any]:
|
||||
"""Get character action logs from the Artifacts API.
|
||||
"""Get automation action logs from the database.
|
||||
|
||||
This endpoint retrieves the character's recent action logs directly
|
||||
from the game server.
|
||||
Joins automation_logs -> automation_runs -> automation_configs
|
||||
to include character_name with each log entry.
|
||||
"""
|
||||
client = _get_client(request)
|
||||
async with async_session_factory() as db:
|
||||
stmt = (
|
||||
select(
|
||||
AutomationLog.id,
|
||||
AutomationLog.action_type,
|
||||
AutomationLog.details,
|
||||
AutomationLog.success,
|
||||
AutomationLog.created_at,
|
||||
AutomationConfig.character_name,
|
||||
)
|
||||
.join(AutomationRun, AutomationLog.run_id == AutomationRun.id)
|
||||
.join(AutomationConfig, AutomationRun.config_id == AutomationConfig.id)
|
||||
.order_by(AutomationLog.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
try:
|
||||
if character:
|
||||
# Get logs for a specific character
|
||||
char_data = await client.get_character(character)
|
||||
return {
|
||||
"character": character,
|
||||
"logs": [], # The API doesn't have a dedicated logs endpoint per character;
|
||||
# action data comes from the automation logs in our DB
|
||||
"character_data": {
|
||||
"name": char_data.name,
|
||||
"level": char_data.level,
|
||||
"xp": char_data.xp,
|
||||
"gold": char_data.gold,
|
||||
"x": char_data.x,
|
||||
"y": char_data.y,
|
||||
"task": char_data.task,
|
||||
"task_progress": char_data.task_progress,
|
||||
"task_total": char_data.task_total,
|
||||
},
|
||||
stmt = stmt.where(AutomationConfig.character_name == character)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return {
|
||||
"logs": [
|
||||
{
|
||||
"id": row.id,
|
||||
"character_name": row.character_name,
|
||||
"action_type": row.action_type,
|
||||
"details": row.details,
|
||||
"success": row.success,
|
||||
"created_at": row.created_at.isoformat(),
|
||||
}
|
||||
else:
|
||||
# Get all characters as a summary
|
||||
characters = await client.get_characters()
|
||||
return {
|
||||
"characters": [
|
||||
{
|
||||
"name": c.name,
|
||||
"level": c.level,
|
||||
"xp": c.xp,
|
||||
"gold": c.gold,
|
||||
"x": c.x,
|
||||
"y": c.y,
|
||||
}
|
||||
for c in characters
|
||||
],
|
||||
}
|
||||
except HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code,
|
||||
detail=f"Artifacts API error: {exc.response.text}",
|
||||
) from exc
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/analytics")
|
||||
|
|
|
|||
|
|
@ -203,13 +203,16 @@ class ArtifactsClient:
|
|||
self,
|
||||
path: str,
|
||||
page_size: int = 100,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch all pages from a paginated endpoint."""
|
||||
all_items: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
base_params = dict(params) if params else {}
|
||||
|
||||
while True:
|
||||
result = await self._get(path, params={"page": page, "size": page_size})
|
||||
req_params = {**base_params, "page": page, "size": page_size}
|
||||
result = await self._get(path, params=req_params)
|
||||
data = result.get("data", [])
|
||||
all_items.extend(data)
|
||||
|
||||
|
|
@ -311,13 +314,30 @@ class ArtifactsClient:
|
|||
result = await self._get("/my/bank")
|
||||
return result.get("data", {})
|
||||
|
||||
async def browse_ge_orders(
|
||||
self,
|
||||
code: str | None = None,
|
||||
order_type: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Browse ALL active Grand Exchange orders (public endpoint)."""
|
||||
params: dict[str, Any] = {}
|
||||
if code:
|
||||
params["code"] = code
|
||||
if order_type:
|
||||
params["type"] = order_type
|
||||
return await self._get_paginated("/grandexchange/orders", params=params)
|
||||
|
||||
async def get_ge_orders(self) -> list[dict[str, Any]]:
|
||||
result = await self._get("/my/grandexchange/orders")
|
||||
return result.get("data", [])
|
||||
"""Get the authenticated account's own active GE orders."""
|
||||
return await self._get_paginated("/my/grandexchange/orders")
|
||||
|
||||
async def get_ge_history(self) -> list[dict[str, Any]]:
|
||||
result = await self._get("/my/grandexchange/history")
|
||||
return result.get("data", [])
|
||||
"""Get the authenticated account's GE transaction history."""
|
||||
return await self._get_paginated("/my/grandexchange/history")
|
||||
|
||||
async def get_ge_sell_history(self, item_code: str) -> list[dict[str, Any]]:
|
||||
"""Get public sale history for a specific item (last 7 days)."""
|
||||
return await self._get_paginated(f"/grandexchange/history/{item_code}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action endpoints
|
||||
|
|
|
|||
|
|
@ -27,8 +27,22 @@ class ExchangeService:
|
|||
# Order and history queries (pass-through to API with enrichment)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_orders(self, client: ArtifactsClient) -> list[dict[str, Any]]:
|
||||
"""Get all active GE orders for the account.
|
||||
async def browse_orders(
|
||||
self,
|
||||
client: ArtifactsClient,
|
||||
code: str | None = None,
|
||||
order_type: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Browse all active GE orders on the market (public).
|
||||
|
||||
Returns
|
||||
-------
|
||||
List of order dicts from the Artifacts API.
|
||||
"""
|
||||
return await client.browse_ge_orders(code=code, order_type=order_type)
|
||||
|
||||
async def get_my_orders(self, client: ArtifactsClient) -> list[dict[str, Any]]:
|
||||
"""Get the authenticated account's own active GE orders.
|
||||
|
||||
Returns
|
||||
-------
|
||||
|
|
@ -45,6 +59,17 @@ class ExchangeService:
|
|||
"""
|
||||
return await client.get_ge_history()
|
||||
|
||||
async def get_sell_history(
|
||||
self, client: ArtifactsClient, item_code: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get public sale history for a specific item.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List of sale history dicts from the Artifacts API.
|
||||
"""
|
||||
return await client.get_ge_sell_history(item_code)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Price capture
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -63,7 +88,7 @@ class ExchangeService:
|
|||
Number of price entries captured.
|
||||
"""
|
||||
try:
|
||||
orders = await client.get_ge_orders()
|
||||
orders = await client.browse_ge_orders()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch GE orders for price capture")
|
||||
return 0
|
||||
|
|
@ -88,7 +113,7 @@ class ExchangeService:
|
|||
|
||||
price = order.get("price", 0)
|
||||
quantity = order.get("quantity", 0)
|
||||
order_type = order.get("order", "") # "buy" or "sell"
|
||||
order_type = order.get("type", "") # "buy" or "sell"
|
||||
|
||||
item_prices[code]["volume"] += quantity
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ from app.engine.pathfinder import Pathfinder
|
|||
from app.schemas.game import (
|
||||
CharacterSchema,
|
||||
ContentSchema,
|
||||
CraftItem,
|
||||
CraftSchema,
|
||||
EffectSchema,
|
||||
InventorySlot,
|
||||
ItemSchema,
|
||||
MapSchema,
|
||||
MonsterSchema,
|
||||
ResourceSchema,
|
||||
|
|
@ -118,3 +122,37 @@ def pathfinder_with_maps(make_map_tile):
|
|||
return pf
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_item():
|
||||
"""Factory fixture that returns an ItemSchema with sensible defaults."""
|
||||
|
||||
def _factory(**overrides) -> ItemSchema:
|
||||
defaults = {
|
||||
"name": "Iron Sword",
|
||||
"code": "iron_sword",
|
||||
"level": 10,
|
||||
"type": "weapon",
|
||||
"subtype": "sword",
|
||||
"effects": [],
|
||||
"craft": None,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
|
||||
# Convert raw effect dicts to EffectSchema instances if needed
|
||||
raw_effects = defaults.get("effects", [])
|
||||
if raw_effects and isinstance(raw_effects[0], dict):
|
||||
defaults["effects"] = [EffectSchema(**e) for e in raw_effects]
|
||||
|
||||
# Convert raw craft dict to CraftSchema if needed
|
||||
raw_craft = defaults.get("craft")
|
||||
if raw_craft and isinstance(raw_craft, dict):
|
||||
if "items" in raw_craft and raw_craft["items"]:
|
||||
if isinstance(raw_craft["items"][0], dict):
|
||||
raw_craft["items"] = [CraftItem(**ci) for ci in raw_craft["items"]]
|
||||
defaults["craft"] = CraftSchema(**raw_craft)
|
||||
|
||||
return ItemSchema(**defaults)
|
||||
|
||||
return _factory
|
||||
|
|
|
|||
109
backend/tests/test_base_strategy.py
Normal file
109
backend/tests/test_base_strategy.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Tests for BaseStrategy static helpers."""
|
||||
|
||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||
from app.schemas.game import InventorySlot
|
||||
|
||||
|
||||
class TestInventoryHelpers:
|
||||
"""Tests for inventory-related static methods."""
|
||||
|
||||
def test_inventory_used_slots_empty(self, make_character):
|
||||
char = make_character(inventory=[])
|
||||
assert BaseStrategy._inventory_used_slots(char) == 0
|
||||
|
||||
def test_inventory_used_slots_with_items(self, make_character):
|
||||
items = [InventorySlot(slot=i, code=f"item_{i}", quantity=1) for i in range(5)]
|
||||
char = make_character(inventory=items)
|
||||
assert BaseStrategy._inventory_used_slots(char) == 5
|
||||
|
||||
def test_inventory_free_slots_all_free(self, make_character):
|
||||
char = make_character(inventory_max_items=20, inventory=[])
|
||||
assert BaseStrategy._inventory_free_slots(char) == 20
|
||||
|
||||
def test_inventory_free_slots_partially_used(self, make_character):
|
||||
items = [InventorySlot(slot=i, code=f"item_{i}", quantity=1) for i in range(8)]
|
||||
char = make_character(inventory_max_items=20, inventory=items)
|
||||
assert BaseStrategy._inventory_free_slots(char) == 12
|
||||
|
||||
def test_inventory_free_slots_full(self, make_character):
|
||||
items = [InventorySlot(slot=i, code=f"item_{i}", quantity=1) for i in range(20)]
|
||||
char = make_character(inventory_max_items=20, inventory=items)
|
||||
assert BaseStrategy._inventory_free_slots(char) == 0
|
||||
|
||||
|
||||
class TestHpPercent:
|
||||
"""Tests for HP percentage calculation."""
|
||||
|
||||
def test_full_health(self, make_character):
|
||||
char = make_character(hp=100, max_hp=100)
|
||||
assert BaseStrategy._hp_percent(char) == 100.0
|
||||
|
||||
def test_half_health(self, make_character):
|
||||
char = make_character(hp=50, max_hp=100)
|
||||
assert BaseStrategy._hp_percent(char) == 50.0
|
||||
|
||||
def test_zero_health(self, make_character):
|
||||
char = make_character(hp=0, max_hp=100)
|
||||
assert BaseStrategy._hp_percent(char) == 0.0
|
||||
|
||||
def test_zero_max_hp_returns_100(self, make_character):
|
||||
char = make_character(hp=0, max_hp=0)
|
||||
assert BaseStrategy._hp_percent(char) == 100.0
|
||||
|
||||
|
||||
class TestIsAt:
|
||||
"""Tests for position checking."""
|
||||
|
||||
def test_at_position(self, make_character):
|
||||
char = make_character(x=5, y=10)
|
||||
assert BaseStrategy._is_at(char, 5, 10) is True
|
||||
|
||||
def test_not_at_position(self, make_character):
|
||||
char = make_character(x=5, y=10)
|
||||
assert BaseStrategy._is_at(char, 0, 0) is False
|
||||
|
||||
def test_wrong_x_only(self, make_character):
|
||||
char = make_character(x=5, y=10)
|
||||
assert BaseStrategy._is_at(char, 6, 10) is False
|
||||
|
||||
def test_wrong_y_only(self, make_character):
|
||||
char = make_character(x=5, y=10)
|
||||
assert BaseStrategy._is_at(char, 5, 11) is False
|
||||
|
||||
|
||||
class TestActionPlan:
|
||||
"""Tests for ActionPlan dataclass."""
|
||||
|
||||
def test_create_with_defaults(self):
|
||||
plan = ActionPlan(ActionType.MOVE)
|
||||
assert plan.action_type == ActionType.MOVE
|
||||
assert plan.params == {}
|
||||
assert plan.reason == ""
|
||||
|
||||
def test_create_with_params(self):
|
||||
plan = ActionPlan(
|
||||
ActionType.MOVE,
|
||||
params={"x": 5, "y": 10},
|
||||
reason="Moving to target",
|
||||
)
|
||||
assert plan.params == {"x": 5, "y": 10}
|
||||
assert plan.reason == "Moving to target"
|
||||
|
||||
|
||||
class TestActionType:
|
||||
"""Tests for ActionType enum values."""
|
||||
|
||||
def test_all_action_types_exist(self):
|
||||
expected = {
|
||||
"move", "fight", "gather", "rest", "equip", "unequip",
|
||||
"use_item", "deposit_item", "withdraw_item", "craft", "recycle",
|
||||
"ge_buy", "ge_sell", "ge_cancel",
|
||||
"task_new", "task_trade", "task_complete", "task_exchange",
|
||||
"idle", "complete",
|
||||
}
|
||||
actual = {at.value for at in ActionType}
|
||||
assert actual == expected
|
||||
|
||||
def test_action_type_is_string(self):
|
||||
assert isinstance(ActionType.MOVE.value, str)
|
||||
assert ActionType.MOVE == "move"
|
||||
304
backend/tests/test_crafting_strategy.py
Normal file
304
backend/tests/test_crafting_strategy.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""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
|
||||
226
backend/tests/test_equipment_optimizer.py
Normal file
226
backend/tests/test_equipment_optimizer.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"""Tests for the EquipmentOptimizer decision maker."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.decision.equipment_optimizer import (
|
||||
EquipmentOptimizer,
|
||||
EquipmentSuggestion,
|
||||
)
|
||||
from app.schemas.game import EffectSchema, InventorySlot, ItemSchema
|
||||
|
||||
|
||||
class TestScoreItem:
|
||||
"""Tests for the _score_item static method."""
|
||||
|
||||
def test_score_none_item(self):
|
||||
assert EquipmentOptimizer._score_item(None) == 0.0
|
||||
|
||||
def test_score_item_with_attack_effects(self, make_item):
|
||||
item = make_item(
|
||||
code="fire_sword",
|
||||
level=10,
|
||||
effects=[
|
||||
EffectSchema(name="attack_fire", value=20),
|
||||
EffectSchema(name="attack_earth", value=10),
|
||||
],
|
||||
)
|
||||
score = EquipmentOptimizer._score_item(item)
|
||||
# 20 + 10 + (10 * 0.1 level bonus) = 31.0
|
||||
assert score == pytest.approx(31.0)
|
||||
|
||||
def test_score_item_with_defense_effects(self, make_item):
|
||||
item = make_item(
|
||||
code="iron_shield",
|
||||
level=5,
|
||||
type="shield",
|
||||
effects=[
|
||||
EffectSchema(name="res_fire", value=15),
|
||||
EffectSchema(name="res_water", value=10),
|
||||
],
|
||||
)
|
||||
score = EquipmentOptimizer._score_item(item)
|
||||
# 15 + 10 + (5 * 0.1) = 25.5
|
||||
assert score == pytest.approx(25.5)
|
||||
|
||||
def test_score_item_with_hp_weighted_less(self, make_item):
|
||||
item = make_item(
|
||||
code="hp_ring",
|
||||
level=1,
|
||||
type="ring",
|
||||
effects=[EffectSchema(name="hp", value=100)],
|
||||
)
|
||||
score = EquipmentOptimizer._score_item(item)
|
||||
# 100 * 0.5 + (1 * 0.1) = 50.1
|
||||
assert score == pytest.approx(50.1)
|
||||
|
||||
def test_score_item_with_damage_weighted_more(self, make_item):
|
||||
item = make_item(
|
||||
code="dmg_amulet",
|
||||
level=1,
|
||||
type="amulet",
|
||||
effects=[EffectSchema(name="dmg_fire", value=10)],
|
||||
)
|
||||
score = EquipmentOptimizer._score_item(item)
|
||||
# 10 * 1.5 + (1 * 0.1) = 15.1
|
||||
assert score == pytest.approx(15.1)
|
||||
|
||||
def test_score_item_level_bonus_as_tiebreaker(self, make_item):
|
||||
low = make_item(code="sword_5", level=5, effects=[EffectSchema(name="attack_fire", value=10)])
|
||||
high = make_item(code="sword_10", level=10, effects=[EffectSchema(name="attack_fire", value=10)])
|
||||
|
||||
assert EquipmentOptimizer._score_item(high) > EquipmentOptimizer._score_item(low)
|
||||
|
||||
def test_score_item_with_no_effects(self, make_item):
|
||||
item = make_item(code="plain_sword", level=5, effects=[])
|
||||
score = EquipmentOptimizer._score_item(item)
|
||||
# Only level bonus: 5 * 0.1 = 0.5
|
||||
assert score == pytest.approx(0.5)
|
||||
|
||||
def test_score_item_with_unknown_effects(self, make_item):
|
||||
item = make_item(
|
||||
code="weird_item",
|
||||
level=1,
|
||||
effects=[EffectSchema(name="unknown_effect", value=100)],
|
||||
)
|
||||
score = EquipmentOptimizer._score_item(item)
|
||||
# Unknown effect not counted: only level bonus 0.1
|
||||
assert score == pytest.approx(0.1)
|
||||
|
||||
|
||||
class TestSuggestEquipment:
|
||||
"""Tests for the suggest_equipment method."""
|
||||
|
||||
def test_empty_available_items(self, make_character):
|
||||
optimizer = EquipmentOptimizer()
|
||||
char = make_character(level=10)
|
||||
analysis = optimizer.suggest_equipment(char, [])
|
||||
|
||||
assert analysis.suggestions == []
|
||||
assert analysis.total_current_score == 0.0
|
||||
assert analysis.total_best_score == 0.0
|
||||
|
||||
def test_suggest_better_weapon(self, make_character, make_item):
|
||||
optimizer = EquipmentOptimizer()
|
||||
|
||||
current_weapon = make_item(
|
||||
code="rusty_sword",
|
||||
level=1,
|
||||
type="weapon",
|
||||
effects=[EffectSchema(name="attack_fire", value=5)],
|
||||
)
|
||||
better_weapon = make_item(
|
||||
code="iron_sword",
|
||||
level=5,
|
||||
type="weapon",
|
||||
effects=[EffectSchema(name="attack_fire", value=20)],
|
||||
)
|
||||
char = make_character(level=10, weapon_slot="rusty_sword")
|
||||
analysis = optimizer.suggest_equipment(char, [current_weapon, better_weapon])
|
||||
|
||||
weapon_suggestions = [s for s in analysis.suggestions if s.slot == "weapon_slot"]
|
||||
assert len(weapon_suggestions) == 1
|
||||
assert weapon_suggestions[0].suggested_item_code == "iron_sword"
|
||||
assert weapon_suggestions[0].improvement > 0
|
||||
|
||||
def test_no_suggestion_when_best_is_equipped(self, make_character, make_item):
|
||||
optimizer = EquipmentOptimizer()
|
||||
|
||||
best_weapon = make_item(
|
||||
code="best_sword",
|
||||
level=5,
|
||||
type="weapon",
|
||||
effects=[EffectSchema(name="attack_fire", value=50)],
|
||||
)
|
||||
char = make_character(level=10, weapon_slot="best_sword")
|
||||
analysis = optimizer.suggest_equipment(char, [best_weapon])
|
||||
|
||||
weapon_suggestions = [s for s in analysis.suggestions if s.slot == "weapon_slot"]
|
||||
assert len(weapon_suggestions) == 0
|
||||
|
||||
def test_item_too_high_level_not_suggested(self, make_character, make_item):
|
||||
optimizer = EquipmentOptimizer()
|
||||
|
||||
high_level_weapon = make_item(
|
||||
code="dragon_sword",
|
||||
level=50,
|
||||
type="weapon",
|
||||
effects=[EffectSchema(name="attack_fire", value=100)],
|
||||
)
|
||||
char = make_character(level=10, weapon_slot="")
|
||||
analysis = optimizer.suggest_equipment(char, [high_level_weapon])
|
||||
|
||||
# Too high level, should not be suggested
|
||||
weapon_suggestions = [s for s in analysis.suggestions if s.slot == "weapon_slot"]
|
||||
assert len(weapon_suggestions) == 0
|
||||
|
||||
def test_suggestions_sorted_by_improvement(self, make_character, make_item):
|
||||
optimizer = EquipmentOptimizer()
|
||||
|
||||
weapon = make_item(
|
||||
code="great_sword",
|
||||
level=5,
|
||||
type="weapon",
|
||||
effects=[EffectSchema(name="attack_fire", value=30)],
|
||||
)
|
||||
shield = make_item(
|
||||
code="great_shield",
|
||||
level=5,
|
||||
type="shield",
|
||||
effects=[EffectSchema(name="res_fire", value=50)],
|
||||
)
|
||||
char = make_character(level=10, weapon_slot="", shield_slot="")
|
||||
analysis = optimizer.suggest_equipment(char, [weapon, shield])
|
||||
|
||||
# Both should be suggested; shield has higher improvement
|
||||
assert len(analysis.suggestions) >= 2
|
||||
# Sorted descending by improvement
|
||||
for i in range(len(analysis.suggestions) - 1):
|
||||
assert analysis.suggestions[i].improvement >= analysis.suggestions[i + 1].improvement
|
||||
|
||||
def test_multiple_slot_types_for_rings(self, make_character, make_item):
|
||||
optimizer = EquipmentOptimizer()
|
||||
|
||||
ring = make_item(
|
||||
code="power_ring",
|
||||
level=5,
|
||||
type="ring",
|
||||
effects=[EffectSchema(name="attack_fire", value=10)],
|
||||
)
|
||||
char = make_character(level=10, ring1_slot="", ring2_slot="")
|
||||
analysis = optimizer.suggest_equipment(char, [ring])
|
||||
|
||||
ring_suggestions = [
|
||||
s for s in analysis.suggestions if s.slot in ("ring1_slot", "ring2_slot")
|
||||
]
|
||||
# Both ring slots should get the suggestion
|
||||
assert len(ring_suggestions) == 2
|
||||
|
||||
def test_total_scores_computed(self, make_character, make_item):
|
||||
optimizer = EquipmentOptimizer()
|
||||
|
||||
weapon = make_item(
|
||||
code="iron_sword",
|
||||
level=5,
|
||||
type="weapon",
|
||||
effects=[EffectSchema(name="attack_fire", value=10)],
|
||||
)
|
||||
char = make_character(level=10, weapon_slot="")
|
||||
analysis = optimizer.suggest_equipment(char, [weapon])
|
||||
|
||||
assert analysis.total_best_score >= analysis.total_current_score
|
||||
|
||||
def test_empty_slot_shows_empty_in_suggestion(self, make_character, make_item):
|
||||
optimizer = EquipmentOptimizer()
|
||||
|
||||
weapon = make_item(
|
||||
code="iron_sword",
|
||||
level=5,
|
||||
type="weapon",
|
||||
effects=[EffectSchema(name="attack_fire", value=10)],
|
||||
)
|
||||
char = make_character(level=10, weapon_slot="")
|
||||
analysis = optimizer.suggest_equipment(char, [weapon])
|
||||
|
||||
weapon_suggestions = [s for s in analysis.suggestions if s.slot == "weapon_slot"]
|
||||
assert len(weapon_suggestions) == 1
|
||||
assert weapon_suggestions[0].current_item_code == "(empty)"
|
||||
345
backend/tests/test_leveling_strategy.py
Normal file
345
backend/tests/test_leveling_strategy.py
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
"""Tests for the LevelingStrategy state machine."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.strategies.base import ActionType
|
||||
from app.engine.strategies.leveling import LevelingStrategy
|
||||
from app.schemas.game import InventorySlot, ResourceSchema
|
||||
|
||||
|
||||
class TestLevelingStrategyInitialization:
|
||||
"""Tests for LevelingStrategy creation."""
|
||||
|
||||
def test_initial_state(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "resource", "copper_rocks")])
|
||||
strategy = LevelingStrategy({}, pf)
|
||||
assert strategy.get_state() == "evaluate"
|
||||
|
||||
def test_target_skill_config(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "resource", "copper_rocks")])
|
||||
strategy = LevelingStrategy({"target_skill": "mining"}, pf)
|
||||
assert strategy._target_skill == "mining"
|
||||
|
||||
def test_max_level_config(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "resource", "copper_rocks")])
|
||||
strategy = LevelingStrategy({"max_level": 30}, pf)
|
||||
assert strategy._max_level == 30
|
||||
|
||||
|
||||
class TestLevelingStrategyEvaluation:
|
||||
"""Tests for skill evaluation and target selection."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_picks_target_skill_when_specified(
|
||||
self, make_character, pathfinder_with_maps
|
||||
):
|
||||
pf = pathfinder_with_maps([
|
||||
(3, 3, "resource", "copper_rocks"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
|
||||
strategy = LevelingStrategy(
|
||||
{"target_skill": "mining"}, pf, resources_data=resources
|
||||
)
|
||||
char = make_character(x=0, y=0, mining_level=5)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.MOVE
|
||||
assert strategy._chosen_skill == "mining"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_picks_lowest_skill_when_no_target(
|
||||
self, make_character, pathfinder_with_maps
|
||||
):
|
||||
pf = pathfinder_with_maps([
|
||||
(3, 3, "resource", "ash_tree"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
resources = [
|
||||
ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1),
|
||||
ResourceSchema(name="Ash Tree", code="ash_tree", skill="woodcutting", level=1),
|
||||
ResourceSchema(name="Gudgeon Spot", code="gudgeon_spot", skill="fishing", level=1),
|
||||
]
|
||||
strategy = LevelingStrategy({}, pf, resources_data=resources)
|
||||
char = make_character(
|
||||
x=0, y=0,
|
||||
mining_level=10,
|
||||
woodcutting_level=3, # lowest
|
||||
fishing_level=7,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert strategy._chosen_skill == "woodcutting"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_when_max_level_reached(
|
||||
self, make_character, pathfinder_with_maps
|
||||
):
|
||||
pf = pathfinder_with_maps([
|
||||
(3, 3, "resource", "copper_rocks"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
|
||||
strategy = LevelingStrategy(
|
||||
{"target_skill": "mining", "max_level": 10},
|
||||
pf,
|
||||
resources_data=resources,
|
||||
)
|
||||
char = make_character(x=0, y=0, mining_level=10)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.COMPLETE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_when_no_skill_found(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = LevelingStrategy({}, pf)
|
||||
# All skills at max_level with exclude set
|
||||
strategy._max_level = 5
|
||||
char = make_character(
|
||||
x=0, y=0,
|
||||
mining_level=999,
|
||||
woodcutting_level=999,
|
||||
fishing_level=999,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
# Should complete since all skills are above max_level
|
||||
assert plan.action_type == ActionType.COMPLETE
|
||||
|
||||
|
||||
class TestLevelingStrategyGathering:
|
||||
"""Tests for gathering activity."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_at_resource(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(3, 3, "resource", "copper_rocks"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
|
||||
strategy = LevelingStrategy(
|
||||
{"target_skill": "mining"}, pf, resources_data=resources
|
||||
)
|
||||
char = make_character(
|
||||
x=3, y=3,
|
||||
mining_level=5,
|
||||
inventory_max_items=20,
|
||||
)
|
||||
|
||||
# First call evaluates and moves; simulate being at target
|
||||
plan = await strategy.next_action(char)
|
||||
# Since we're at the target, should get GATHER
|
||||
assert plan.action_type == ActionType.GATHER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deposit_when_inventory_full(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(3, 3, "resource", "copper_rocks"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
|
||||
strategy = LevelingStrategy(
|
||||
{"target_skill": "mining"}, pf, resources_data=resources
|
||||
)
|
||||
|
||||
items = [InventorySlot(slot=i, code="copper_ore", quantity=1) for i in range(20)]
|
||||
char = make_character(
|
||||
x=3, y=3,
|
||||
mining_level=5,
|
||||
inventory_max_items=20,
|
||||
inventory=items,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
# Should move to bank
|
||||
assert plan.action_type == ActionType.MOVE
|
||||
assert plan.params == {"x": 10, "y": 0}
|
||||
|
||||
|
||||
class TestLevelingStrategyCombat:
|
||||
"""Tests for combat leveling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fight_for_combat_leveling(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(3, 3, "monster", "chicken"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = LevelingStrategy({"target_skill": "combat"}, pf)
|
||||
char = make_character(
|
||||
x=3, y=3,
|
||||
hp=100, max_hp=100,
|
||||
level=5,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.FIGHT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heal_during_combat(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(3, 3, "monster", "chicken"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = LevelingStrategy({"target_skill": "combat"}, pf)
|
||||
# Simulate: at monster, fighting, low HP
|
||||
strategy._state = strategy._state.__class__("fight")
|
||||
strategy._chosen_monster_code = "chicken"
|
||||
strategy._target_pos = (3, 3)
|
||||
|
||||
char = make_character(
|
||||
x=3, y=3,
|
||||
hp=30, max_hp=100,
|
||||
level=5,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.REST
|
||||
|
||||
|
||||
class TestLevelingStrategyCraftingSkills:
|
||||
"""Tests for crafting skill leveling via gathering."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crafting_skill_mapped_to_gathering(
|
||||
self, make_character, pathfinder_with_maps
|
||||
):
|
||||
pf = pathfinder_with_maps([
|
||||
(3, 3, "resource", "copper_rocks"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
resources = [
|
||||
ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1),
|
||||
]
|
||||
strategy = LevelingStrategy(
|
||||
{"target_skill": "weaponcrafting"}, pf, resources_data=resources
|
||||
)
|
||||
char = make_character(
|
||||
x=0, y=0,
|
||||
mining_level=5,
|
||||
weaponcrafting_level=3,
|
||||
inventory_max_items=20,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
# Weaponcrafting maps to mining, so should find mining resource
|
||||
assert plan.action_type == ActionType.MOVE
|
||||
assert plan.params == {"x": 3, "y": 3}
|
||||
|
||||
def test_crafting_to_gathering_mapping(self):
|
||||
"""Verify all crafting skills map to gathering skills."""
|
||||
assert LevelingStrategy._crafting_to_gathering("weaponcrafting") == "mining"
|
||||
assert LevelingStrategy._crafting_to_gathering("gearcrafting") == "mining"
|
||||
assert LevelingStrategy._crafting_to_gathering("jewelrycrafting") == "mining"
|
||||
assert LevelingStrategy._crafting_to_gathering("cooking") == "fishing"
|
||||
assert LevelingStrategy._crafting_to_gathering("alchemy") == "mining"
|
||||
assert LevelingStrategy._crafting_to_gathering("unknown") == ""
|
||||
|
||||
|
||||
class TestLevelingStrategyResourceSelection:
|
||||
"""Tests for resource target selection based on skill level."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefers_higher_level_resource_within_range(
|
||||
self, make_character, pathfinder_with_maps
|
||||
):
|
||||
pf = pathfinder_with_maps([
|
||||
(1, 1, "resource", "copper_rocks"),
|
||||
(2, 2, "resource", "iron_rocks"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
resources = [
|
||||
ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1),
|
||||
ResourceSchema(name="Iron Rocks", code="iron_rocks", skill="mining", level=6),
|
||||
]
|
||||
strategy = LevelingStrategy(
|
||||
{"target_skill": "mining"}, pf, resources_data=resources
|
||||
)
|
||||
char = make_character(
|
||||
x=0, y=0,
|
||||
mining_level=5,
|
||||
inventory_max_items=20,
|
||||
)
|
||||
|
||||
await strategy.next_action(char)
|
||||
# Should prefer iron_rocks (level 6, within +3 of skill level 5)
|
||||
assert strategy._chosen_resource_code == "iron_rocks"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_resources_data(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "resource", "copper_rocks")])
|
||||
strategy = LevelingStrategy({}, pf)
|
||||
assert strategy._resources_data == []
|
||||
|
||||
resources = [
|
||||
ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1),
|
||||
]
|
||||
strategy.set_resources_data(resources)
|
||||
assert len(strategy._resources_data) == 1
|
||||
|
||||
|
||||
class TestLevelingStrategyDeposit:
|
||||
"""Tests for deposit behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deposit_items_at_bank(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(3, 3, "resource", "copper_rocks"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = LevelingStrategy({"target_skill": "mining"}, pf)
|
||||
strategy._state = strategy._state.__class__("deposit")
|
||||
strategy._chosen_skill = "mining"
|
||||
strategy._target_pos = (3, 3)
|
||||
|
||||
char = make_character(
|
||||
x=10, y=0,
|
||||
mining_level=5,
|
||||
inventory=[InventorySlot(slot=0, code="copper_ore", quantity=10)],
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.DEPOSIT_ITEM
|
||||
assert plan.params["code"] == "copper_ore"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_re_evaluate_after_deposit(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(3, 3, "resource", "copper_rocks"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
|
||||
strategy = LevelingStrategy(
|
||||
{"target_skill": "mining"}, pf, resources_data=resources
|
||||
)
|
||||
strategy._state = strategy._state.__class__("deposit")
|
||||
strategy._chosen_skill = "mining"
|
||||
|
||||
char = make_character(
|
||||
x=10, y=0,
|
||||
mining_level=5,
|
||||
inventory=[],
|
||||
inventory_max_items=20,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
# Empty inventory triggers re-evaluation -> move to target
|
||||
assert plan.action_type == ActionType.MOVE
|
||||
|
||||
|
||||
class TestLevelingStrategyGetState:
|
||||
"""Tests for state reporting."""
|
||||
|
||||
def test_state_with_chosen_skill(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "resource", "copper_rocks")])
|
||||
strategy = LevelingStrategy({}, pf)
|
||||
strategy._chosen_skill = "mining"
|
||||
assert "mining" in strategy.get_state()
|
||||
|
||||
def test_state_without_chosen_skill(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "resource", "copper_rocks")])
|
||||
strategy = LevelingStrategy({}, pf)
|
||||
assert strategy.get_state() == "evaluate"
|
||||
314
backend/tests/test_task_strategy.py
Normal file
314
backend/tests/test_task_strategy.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
"""Tests for the TaskStrategy state machine."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.strategies.base import ActionType
|
||||
from app.engine.strategies.task import TaskStrategy
|
||||
from app.schemas.game import InventorySlot
|
||||
|
||||
|
||||
class TestTaskStrategyInitialization:
|
||||
"""Tests for TaskStrategy creation."""
|
||||
|
||||
def test_initial_state(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "tasks_master", "tasks_master")])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
assert strategy.get_state() == "move_to_taskmaster"
|
||||
|
||||
def test_max_tasks_config(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "tasks_master", "tasks_master")])
|
||||
strategy = TaskStrategy({"max_tasks": 5}, pf)
|
||||
assert strategy._max_tasks == 5
|
||||
|
||||
def test_auto_exchange_default(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "tasks_master", "tasks_master")])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
assert strategy._auto_exchange is True
|
||||
|
||||
|
||||
class TestTaskStrategyMovement:
|
||||
"""Tests for movement to task master."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_to_taskmaster(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
char = make_character(x=0, y=0)
|
||||
|
||||
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_idle_when_no_taskmaster(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(10, 0, "bank", "bank")])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
char = make_character(x=0, y=0)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.IDLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_to_evaluate_when_has_task(self, make_character, pathfinder_with_maps):
|
||||
"""If character already has a task, skip directly to evaluation."""
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(3, 3, "monster", "chicken"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
char = make_character(
|
||||
x=0, y=0,
|
||||
task="chicken",
|
||||
task_type="monsters",
|
||||
task_total=5,
|
||||
task_progress=0,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
# Should move to the monster target, not to taskmaster
|
||||
assert plan.action_type == ActionType.MOVE
|
||||
assert plan.params == {"x": 3, "y": 3}
|
||||
|
||||
|
||||
class TestTaskStrategyAcceptTask:
|
||||
"""Tests for accepting tasks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_new_task_at_taskmaster(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
char = make_character(x=5, y=5)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.TASK_NEW
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_existing_task_instead_of_accept(
|
||||
self, make_character, pathfinder_with_maps
|
||||
):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(3, 3, "monster", "wolf"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
char = make_character(
|
||||
x=5, y=5,
|
||||
task="wolf",
|
||||
task_type="monsters",
|
||||
task_total=3,
|
||||
task_progress=0,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
# Should go to the monster, not accept a new task
|
||||
assert plan.action_type == ActionType.MOVE
|
||||
assert plan.params == {"x": 3, "y": 3}
|
||||
|
||||
|
||||
class TestTaskStrategyExecution:
|
||||
"""Tests for task action execution."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fight_for_monster_task(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(3, 3, "monster", "chicken"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
char = make_character(
|
||||
x=3, y=3,
|
||||
hp=100, max_hp=100,
|
||||
task="chicken",
|
||||
task_type="monsters",
|
||||
task_total=5,
|
||||
task_progress=2,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.FIGHT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_for_resource_task(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(3, 3, "resource", "copper_rocks"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
char = make_character(
|
||||
x=3, y=3,
|
||||
inventory_max_items=20,
|
||||
task="copper_rocks",
|
||||
task_type="resources",
|
||||
task_total=10,
|
||||
task_progress=3,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.GATHER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heal_when_low_hp_during_monster_task(
|
||||
self, make_character, pathfinder_with_maps
|
||||
):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(3, 3, "monster", "chicken"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
char = make_character(
|
||||
x=3, y=3,
|
||||
hp=30, max_hp=100,
|
||||
task="chicken",
|
||||
task_type="monsters",
|
||||
task_total=5,
|
||||
task_progress=2,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.REST
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deposit_when_inventory_full(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(3, 3, "resource", "copper_rocks"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
items = [InventorySlot(slot=i, code="copper_ore", quantity=1) for i in range(20)]
|
||||
char = make_character(
|
||||
x=3, y=3,
|
||||
inventory_max_items=20,
|
||||
inventory=items,
|
||||
task="copper_rocks",
|
||||
task_type="resources",
|
||||
task_total=30,
|
||||
task_progress=15,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.MOVE
|
||||
assert plan.params == {"x": 10, "y": 0}
|
||||
|
||||
|
||||
class TestTaskStrategyCompletion:
|
||||
"""Tests for task completion flow."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trade_when_task_complete(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(3, 3, "monster", "chicken"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
char = make_character(
|
||||
x=0, y=0,
|
||||
task="chicken",
|
||||
task_type="monsters",
|
||||
task_total=5,
|
||||
task_progress=5, # Complete!
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
# Should move to taskmaster to trade
|
||||
assert plan.action_type == ActionType.MOVE
|
||||
assert plan.params == {"x": 5, "y": 5}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trade_items_at_taskmaster(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({}, pf)
|
||||
strategy._state = strategy._state.__class__("trade_items")
|
||||
strategy._current_task_code = "chicken"
|
||||
strategy._current_task_total = 5
|
||||
|
||||
char = make_character(x=5, y=5)
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.TASK_TRADE
|
||||
assert plan.params["code"] == "chicken"
|
||||
assert plan.params["quantity"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_task_at_taskmaster(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({"auto_exchange": True}, pf)
|
||||
strategy._state = strategy._state.__class__("complete_task")
|
||||
strategy._current_task_code = "chicken"
|
||||
|
||||
char = make_character(x=5, y=5)
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.TASK_COMPLETE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exchange_coins_after_complete(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({"auto_exchange": True}, pf)
|
||||
strategy._state = strategy._state.__class__("exchange_coins")
|
||||
|
||||
char = make_character(x=5, y=5)
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.TASK_EXCHANGE
|
||||
|
||||
|
||||
class TestTaskStrategyMaxTasks:
|
||||
"""Tests for max_tasks limit."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_when_max_tasks_reached(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({"max_tasks": 3}, pf)
|
||||
strategy._tasks_completed = 3
|
||||
|
||||
char = make_character(x=5, y=5)
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.COMPLETE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continue_when_below_max_tasks(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({"max_tasks": 3}, pf)
|
||||
strategy._tasks_completed = 2
|
||||
|
||||
char = make_character(x=0, y=0)
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type != ActionType.COMPLETE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_limit_when_max_tasks_zero(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "tasks_master", "tasks_master"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TaskStrategy({"max_tasks": 0}, pf)
|
||||
strategy._tasks_completed = 100
|
||||
|
||||
char = make_character(x=0, y=0)
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type != ActionType.COMPLETE
|
||||
288
backend/tests/test_trading_strategy.py
Normal file
288
backend/tests/test_trading_strategy.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
"""Tests for the TradingStrategy state machine."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.strategies.base import ActionType
|
||||
from app.engine.strategies.trading import TradingStrategy
|
||||
from app.schemas.game import InventorySlot
|
||||
|
||||
|
||||
class TestTradingStrategyInitialization:
|
||||
"""Tests for TradingStrategy creation and initial state."""
|
||||
|
||||
def test_sell_loot_initial_state(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "sell_loot"}, pf
|
||||
)
|
||||
assert "sell_loot" in strategy.get_state()
|
||||
assert "move_to_bank" in strategy.get_state()
|
||||
|
||||
def test_buy_materials_initial_state(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "grand_exchange", "grand_exchange")])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "buy_materials"}, pf
|
||||
)
|
||||
assert "buy_materials" in strategy.get_state()
|
||||
assert "move_to_ge" in strategy.get_state()
|
||||
|
||||
def test_flip_initial_state(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "grand_exchange", "grand_exchange")])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "flip"}, pf
|
||||
)
|
||||
assert "flip" in strategy.get_state()
|
||||
assert "move_to_ge" in strategy.get_state()
|
||||
|
||||
def test_unknown_mode_defaults_to_sell_loot(self, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "invalid_mode"}, pf
|
||||
)
|
||||
assert "sell_loot" in strategy.get_state()
|
||||
|
||||
|
||||
class TestTradingStrategySellLoot:
|
||||
"""Tests for the sell_loot mode."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_to_bank_first(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "sell_loot", "quantity": 5}, pf
|
||||
)
|
||||
char = make_character(x=0, y=0)
|
||||
|
||||
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_at_bank(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "sell_loot", "quantity": 5}, pf
|
||||
)
|
||||
char = make_character(x=10, y=0, inventory_max_items=20)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.WITHDRAW_ITEM
|
||||
assert plan.params["code"] == "iron_ore"
|
||||
assert plan.params["quantity"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_withdraw_limited_by_free_slots(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "sell_loot", "quantity": 100}, pf
|
||||
)
|
||||
# Only 3 free slots
|
||||
items = [InventorySlot(slot=i, code="junk", quantity=1) for i in range(17)]
|
||||
char = make_character(x=10, y=0, inventory_max_items=20, inventory=items)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.WITHDRAW_ITEM
|
||||
assert plan.params["quantity"] == 3 # min(100, 3 free slots)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_to_ge_after_withdraw(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "sell_loot", "quantity": 5}, pf
|
||||
)
|
||||
# Simulate: withdrew everything
|
||||
strategy._items_withdrawn = 5
|
||||
strategy._state = strategy._state.__class__("withdraw_items")
|
||||
|
||||
char = make_character(x=10, y=0, inventory_max_items=20)
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.MOVE
|
||||
assert plan.params == {"x": 20, "y": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_sell_order_at_ge(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "sell_loot", "quantity": 5, "min_price": 10},
|
||||
pf,
|
||||
)
|
||||
strategy._state = strategy._state.__class__("create_sell_order")
|
||||
char = make_character(
|
||||
x=20, y=0,
|
||||
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=5)],
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.GE_SELL
|
||||
assert plan.params["code"] == "iron_ore"
|
||||
assert plan.params["quantity"] == 5
|
||||
assert plan.params["price"] == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_when_no_items_to_sell(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "sell_loot", "quantity": 5}, pf
|
||||
)
|
||||
strategy._state = strategy._state.__class__("create_sell_order")
|
||||
char = make_character(x=20, y=0, inventory=[])
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.COMPLETE
|
||||
|
||||
|
||||
class TestTradingStrategyBuyMaterials:
|
||||
"""Tests for the buy_materials mode."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_to_ge_first(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "buy_materials", "quantity": 10}, pf
|
||||
)
|
||||
char = make_character(x=0, y=0)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.MOVE
|
||||
assert plan.params == {"x": 20, "y": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_buy_order_at_ge(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "buy_materials", "quantity": 10, "max_price": 50},
|
||||
pf,
|
||||
)
|
||||
char = make_character(x=20, y=0)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.GE_BUY
|
||||
assert plan.params["code"] == "iron_ore"
|
||||
assert plan.params["quantity"] == 10
|
||||
assert plan.params["price"] == 50
|
||||
|
||||
|
||||
class TestTradingStrategyWaiting:
|
||||
"""Tests for the order waiting logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_while_waiting(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "sell_loot"}, pf
|
||||
)
|
||||
strategy._state = strategy._state.__class__("wait_for_order")
|
||||
strategy._wait_cycles = 0
|
||||
char = make_character(x=20, y=0)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.IDLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_orders_after_wait(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "sell_loot"}, pf
|
||||
)
|
||||
strategy._state = strategy._state.__class__("wait_for_order")
|
||||
strategy._wait_cycles = 3 # After 3 cycles, should check
|
||||
char = make_character(x=20, y=0)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.COMPLETE
|
||||
|
||||
|
||||
class TestTradingStrategyNoLocations:
|
||||
"""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([
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "sell_loot"}, pf
|
||||
)
|
||||
char = make_character(x=0, y=0)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.IDLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_when_no_ge(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "buy_materials"}, pf
|
||||
)
|
||||
char = make_character(x=0, y=0)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.IDLE
|
||||
|
||||
|
||||
class TestTradingStrategyDeposit:
|
||||
"""Tests for the deposit_items state."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deposit_items(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "sell_loot"}, pf
|
||||
)
|
||||
strategy._state = strategy._state.__class__("deposit_items")
|
||||
char = make_character(
|
||||
x=10, y=0,
|
||||
inventory=[InventorySlot(slot=0, code="gold_coins", quantity=100)],
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.DEPOSIT_ITEM
|
||||
assert plan.params["code"] == "gold_coins"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_after_all_deposited(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
(20, 0, "grand_exchange", "grand_exchange"),
|
||||
])
|
||||
strategy = TradingStrategy(
|
||||
{"item_code": "iron_ore", "mode": "sell_loot"}, pf
|
||||
)
|
||||
strategy._state = strategy._state.__class__("deposit_items")
|
||||
char = make_character(x=10, y=0, inventory=[])
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.COMPLETE
|
||||
|
|
@ -32,23 +32,13 @@ services:
|
|||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
target: prod
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
depends_on:
|
||||
- backend
|
||||
restart: always
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import {
|
|||
Clock,
|
||||
CalendarDays,
|
||||
Sparkles,
|
||||
Swords,
|
||||
Trees,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
|
|
@ -20,10 +22,18 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useEvents, useEventHistory } from "@/hooks/use-events";
|
||||
import type { GameEvent } from "@/lib/types";
|
||||
import type { ActiveGameEvent, HistoricalEvent } from "@/lib/types";
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return "-";
|
||||
return date.toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
|
|
@ -32,111 +42,86 @@ function formatDate(dateStr: string): string {
|
|||
});
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diffMs = then - now;
|
||||
|
||||
if (diffMs <= 0) return "Ended";
|
||||
|
||||
const diffS = Math.floor(diffMs / 1000);
|
||||
if (diffS < 60) return `${diffS}s remaining`;
|
||||
const diffM = Math.floor(diffS / 60);
|
||||
if (diffM < 60) return `${diffM}m remaining`;
|
||||
const diffH = Math.floor(diffM / 60);
|
||||
if (diffH < 24) return `${diffH}h ${diffM % 60}m remaining`;
|
||||
return `${Math.floor(diffH / 24)}d ${diffH % 24}h remaining`;
|
||||
}
|
||||
|
||||
function getEventDescription(event: GameEvent): string {
|
||||
if (event.data.description && typeof event.data.description === "string") {
|
||||
return event.data.description;
|
||||
}
|
||||
if (event.data.name && typeof event.data.name === "string") {
|
||||
return event.data.name;
|
||||
}
|
||||
return "Game event";
|
||||
}
|
||||
|
||||
function getEventLocation(event: GameEvent): string | null {
|
||||
if (event.data.map && typeof event.data.map === "string") {
|
||||
return event.data.map;
|
||||
}
|
||||
if (
|
||||
event.data.x !== undefined &&
|
||||
event.data.y !== undefined
|
||||
) {
|
||||
return `(${event.data.x}, ${event.data.y})`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getEventExpiry(event: GameEvent): string | null {
|
||||
if (event.data.expiration && typeof event.data.expiration === "string") {
|
||||
return event.data.expiration;
|
||||
}
|
||||
if (event.data.expires_at && typeof event.data.expires_at === "string") {
|
||||
return event.data.expires_at;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||
portal: "text-purple-400 border-purple-500/30",
|
||||
boss: "text-red-400 border-red-500/30",
|
||||
resource: "text-green-400 border-green-500/30",
|
||||
bonus: "text-amber-400 border-amber-500/30",
|
||||
special: "text-cyan-400 border-cyan-500/30",
|
||||
const CONTENT_TYPE_STYLES: Record<string, { icon: typeof Swords; color: string }> = {
|
||||
monster: { icon: Swords, color: "text-red-400 border-red-500/30" },
|
||||
resource: { icon: Trees, color: "text-green-400 border-green-500/30" },
|
||||
};
|
||||
|
||||
function getEventTypeStyle(type: string): string {
|
||||
return EVENT_TYPE_COLORS[type] ?? "text-muted-foreground border-border";
|
||||
}
|
||||
|
||||
function ActiveEventCard({ event }: { event: GameEvent }) {
|
||||
const location = getEventLocation(event);
|
||||
const expiry = getEventExpiry(event);
|
||||
function ActiveEventCard({ event }: { event: ActiveGameEvent }) {
|
||||
const style = CONTENT_TYPE_STYLES[event.content.type] ?? {
|
||||
icon: Sparkles,
|
||||
color: "text-muted-foreground border-border",
|
||||
};
|
||||
const Icon = style.icon;
|
||||
const locations = event.maps.slice(0, 3);
|
||||
|
||||
return (
|
||||
<Card className="py-4">
|
||||
<CardContent className="px-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="size-4 text-amber-400" />
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`capitalize ${getEventTypeStyle(event.type)}`}
|
||||
>
|
||||
{event.type}
|
||||
<Icon className="size-4 text-amber-400" />
|
||||
<Badge variant="outline" className={`capitalize ${style.color}`}>
|
||||
{event.content.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{expiry && (
|
||||
<div className="flex items-center gap-1 text-xs text-amber-400">
|
||||
<Clock className="size-3" />
|
||||
<span>{formatRelativeTime(expiry)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-foreground">{getEventDescription(event)}</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{location && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="size-3" />
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<CalendarDays className="size-3" />
|
||||
<span>{formatDate(event.created_at)}</span>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
<span>{formatDuration(event.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium text-foreground">{event.name}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{event.content.code.replaceAll("_", " ")}
|
||||
</p>
|
||||
|
||||
{locations.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
{locations.map((m) => (
|
||||
<div key={m.map_id} className="flex items-center gap-1">
|
||||
<MapPin className="size-3" />
|
||||
<span>({m.x}, {m.y})</span>
|
||||
</div>
|
||||
))}
|
||||
{event.maps.length > 3 && (
|
||||
<span className="text-muted-foreground/60">
|
||||
+{event.maps.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryRow({ event }: { event: HistoricalEvent }) {
|
||||
const location =
|
||||
event.map_x !== undefined && event.map_y !== undefined
|
||||
? `(${event.map_x}, ${event.map_y})`
|
||||
: "-";
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{event.event_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{event.character_name ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{location}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground text-sm">
|
||||
{formatDate(event.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventsPage() {
|
||||
const { data: activeEvents, isLoading: loadingActive, error } = useEvents();
|
||||
const { data: historicalEvents, isLoading: loadingHistory } =
|
||||
|
|
@ -185,8 +170,8 @@ export default function EventsPage() {
|
|||
|
||||
{activeEvents && activeEvents.length > 0 && (
|
||||
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{activeEvents.map((event, idx) => (
|
||||
<ActiveEventCard key={event.id ?? idx} event={event} />
|
||||
{activeEvents.map((event) => (
|
||||
<ActiveEventCard key={event.code} event={event} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -220,32 +205,14 @@ export default function EventsPage() {
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Character</TableHead>
|
||||
<TableHead>Location</TableHead>
|
||||
<TableHead className="text-right">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedHistory.map((event, idx) => (
|
||||
<TableRow key={event.id ?? idx}>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`capitalize ${getEventTypeStyle(event.type)}`}
|
||||
>
|
||||
{event.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{getEventDescription(event)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{getEventLocation(event) ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground text-sm">
|
||||
{formatDate(event.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{sortedHistory.map((event) => (
|
||||
<HistoryRow key={event.id} event={event} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ShoppingCart,
|
||||
History,
|
||||
TrendingUp,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -29,12 +30,13 @@ import {
|
|||
} from "@/components/ui/table";
|
||||
import {
|
||||
useExchangeOrders,
|
||||
useMyOrders,
|
||||
useExchangeHistory,
|
||||
usePriceHistory,
|
||||
} from "@/hooks/use-exchange";
|
||||
import { PriceChart } from "@/components/exchange/price-chart";
|
||||
import { GameIcon } from "@/components/ui/game-icon";
|
||||
import type { GEOrder } from "@/lib/types";
|
||||
import type { GEOrder, GEHistoryEntry } from "@/lib/types";
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
|
|
@ -51,11 +53,13 @@ function OrdersTable({
|
|||
isLoading,
|
||||
search,
|
||||
emptyMessage,
|
||||
showAccount,
|
||||
}: {
|
||||
orders: GEOrder[];
|
||||
isLoading: boolean;
|
||||
search: string;
|
||||
emptyMessage: string;
|
||||
showAccount?: boolean;
|
||||
}) {
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return orders;
|
||||
|
|
@ -88,6 +92,7 @@ function OrdersTable({
|
|||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Price</TableHead>
|
||||
<TableHead className="text-right">Quantity</TableHead>
|
||||
{showAccount && <TableHead>Account</TableHead>}
|
||||
<TableHead className="text-right">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -118,6 +123,11 @@ function OrdersTable({
|
|||
<TableCell className="text-right tabular-nums">
|
||||
{order.quantity.toLocaleString()}
|
||||
</TableCell>
|
||||
{showAccount && (
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{order.account ?? "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-right text-muted-foreground text-sm">
|
||||
{formatDate(order.created_at)}
|
||||
</TableCell>
|
||||
|
|
@ -128,8 +138,78 @@ function OrdersTable({
|
|||
);
|
||||
}
|
||||
|
||||
function HistoryTable({
|
||||
entries,
|
||||
isLoading,
|
||||
emptyMessage,
|
||||
}: {
|
||||
entries: GEHistoryEntry[];
|
||||
isLoading: boolean;
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<History className="size-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead className="text-right">Price</TableHead>
|
||||
<TableHead className="text-right">Quantity</TableHead>
|
||||
<TableHead>Seller</TableHead>
|
||||
<TableHead>Buyer</TableHead>
|
||||
<TableHead className="text-right">Sold At</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow key={`${entry.order_id}-${entry.sold_at}`}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<GameIcon type="item" code={entry.code} size="sm" />
|
||||
{entry.code}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-amber-400">
|
||||
{entry.price.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{entry.quantity.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{entry.seller}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{entry.buyer}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground text-sm">
|
||||
{formatDate(entry.sold_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExchangePage() {
|
||||
const { data: orders, isLoading: loadingOrders, error: ordersError } = useExchangeOrders();
|
||||
const { data: myOrders, isLoading: loadingMyOrders } = useMyOrders();
|
||||
const { data: history, isLoading: loadingHistory } = useExchangeHistory();
|
||||
|
||||
const [marketSearch, setMarketSearch] = useState("");
|
||||
|
|
@ -172,9 +252,13 @@ export default function ExchangePage() {
|
|||
<ShoppingCart className="size-4" />
|
||||
Market
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="my-orders" className="gap-1.5">
|
||||
<User className="size-4" />
|
||||
My Orders
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="gap-1.5">
|
||||
<History className="size-4" />
|
||||
My Orders
|
||||
Trade History
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="prices" className="gap-1.5">
|
||||
<TrendingUp className="size-4" />
|
||||
|
|
@ -182,7 +266,7 @@ export default function ExchangePage() {
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Market Tab */}
|
||||
{/* Market Tab - Browse all public orders */}
|
||||
<TabsContent value="market" className="space-y-4">
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
|
|
@ -199,6 +283,7 @@ export default function ExchangePage() {
|
|||
orders={orders ?? []}
|
||||
isLoading={loadingOrders}
|
||||
search={marketSearch}
|
||||
showAccount
|
||||
emptyMessage={
|
||||
marketSearch.trim()
|
||||
? `No orders found for "${marketSearch}"`
|
||||
|
|
@ -208,13 +293,24 @@ export default function ExchangePage() {
|
|||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* My Orders Tab */}
|
||||
<TabsContent value="history" className="space-y-4">
|
||||
{/* My Orders Tab - User's own active orders */}
|
||||
<TabsContent value="my-orders" className="space-y-4">
|
||||
<Card>
|
||||
<OrdersTable
|
||||
orders={history ?? []}
|
||||
isLoading={loadingHistory}
|
||||
orders={myOrders ?? []}
|
||||
isLoading={loadingMyOrders}
|
||||
search=""
|
||||
emptyMessage="You have no active orders on the Grand Exchange."
|
||||
/>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Trade History Tab - User's transaction history */}
|
||||
<TabsContent value="history" className="space-y-4">
|
||||
<Card>
|
||||
<HistoryTable
|
||||
entries={history ?? []}
|
||||
isLoading={loadingHistory}
|
||||
emptyMessage="No transaction history found."
|
||||
/>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -41,8 +41,7 @@ const CONTENT_COLORS: Record<string, string> = {
|
|||
|
||||
const EMPTY_COLOR = "#1f2937";
|
||||
const CHARACTER_COLOR = "#facc15";
|
||||
const GRID_LINE_COLOR = "#374151";
|
||||
const BASE_CELL_SIZE = 18;
|
||||
const BASE_CELL_SIZE = 40;
|
||||
const IMAGE_BASE = "https://artifactsmmo.com/images";
|
||||
|
||||
const LEGEND_ITEMS = [
|
||||
|
|
@ -83,7 +82,8 @@ function loadImage(url: string): Promise<HTMLImageElement> {
|
|||
if (cached) return Promise.resolve(cached);
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
// Note: do NOT set crossOrigin – the CDN doesn't send CORS headers,
|
||||
// and we only need to draw the images on canvas (not read pixel data).
|
||||
img.onload = () => {
|
||||
imageCache.set(url, img);
|
||||
resolve(img);
|
||||
|
|
@ -147,7 +147,7 @@ export default function MapPage() {
|
|||
const rafRef = useRef<number>(0);
|
||||
const dragDistRef = useRef(0);
|
||||
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [zoom, setZoom] = useState(0.5);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
|
|
@ -295,14 +295,15 @@ export default function MapPage() {
|
|||
if (px + cellSize < 0 || py + cellSize < 0 || px > width || py > height)
|
||||
continue;
|
||||
|
||||
// Try to draw skin image
|
||||
// Draw skin image – seamless tiles, no gaps
|
||||
const skinImg = tile.skin ? imageCache.get(skinUrl(tile.skin)) : null;
|
||||
|
||||
if (skinImg) {
|
||||
ctx.drawImage(skinImg, px, py, cellSize, cellSize);
|
||||
} else {
|
||||
ctx.fillStyle = getTileColor(tile);
|
||||
ctx.fillRect(px, py, cellSize - 1, cellSize - 1);
|
||||
// Fallback: dark ground color for tiles without skin images
|
||||
ctx.fillStyle = EMPTY_COLOR;
|
||||
ctx.fillRect(px, py, cellSize, cellSize);
|
||||
}
|
||||
|
||||
// Content icon overlay
|
||||
|
|
@ -315,70 +316,79 @@ export default function MapPage() {
|
|||
const iconSize = cellSize * 0.6;
|
||||
const iconX = px + (cellSize - iconSize) / 2;
|
||||
const iconY = py + (cellSize - iconSize) / 2;
|
||||
// Drop shadow for icon readability
|
||||
ctx.shadowColor = "rgba(0,0,0,0.5)";
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.drawImage(iconImg, iconX, iconY, iconSize, iconSize);
|
||||
ctx.shadowColor = "transparent";
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
} else if (!skinImg) {
|
||||
// For bank/workshop/etc without skin, draw a colored indicator dot
|
||||
const dotRadius = Math.max(2, cellSize * 0.15);
|
||||
} else {
|
||||
// For bank/workshop/etc – small colored badge in bottom-right
|
||||
const badgeSize = Math.max(6, cellSize * 0.25);
|
||||
const badgeX = px + cellSize - badgeSize - 2;
|
||||
const badgeY = py + cellSize - badgeSize - 2;
|
||||
ctx.fillStyle = "rgba(0,0,0,0.5)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
px + cellSize / 2,
|
||||
py + cellSize / 2,
|
||||
dotRadius,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
ctx.arc(badgeX + badgeSize / 2, badgeY + badgeSize / 2, badgeSize / 2 + 1, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = CONTENT_COLORS[tile.content.type] ?? "#fff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(badgeX + badgeSize / 2, badgeY + badgeSize / 2, badgeSize / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Grid lines (subtler when images are shown)
|
||||
if (cellSize > 10) {
|
||||
ctx.strokeStyle = skinImg
|
||||
? "rgba(55, 65, 81, 0.3)"
|
||||
: GRID_LINE_COLOR;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.strokeRect(px, py, cellSize - 1, cellSize - 1);
|
||||
}
|
||||
|
||||
// Selected tile highlight
|
||||
// Selected tile highlight – bright outline
|
||||
if (
|
||||
selectedTile &&
|
||||
tile.x === selectedTile.tile.x &&
|
||||
tile.y === selectedTile.tile.y
|
||||
) {
|
||||
ctx.strokeStyle = "#3b82f6";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(px + 1, py + 1, cellSize - 3, cellSize - 3);
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.strokeRect(px + 1, py + 1, cellSize - 2, cellSize - 2);
|
||||
}
|
||||
|
||||
// Coordinate text at high zoom
|
||||
if (cellSize >= 40) {
|
||||
ctx.fillStyle = "rgba(255,255,255,0.6)";
|
||||
ctx.font = `${Math.max(8, cellSize * 0.2)}px sans-serif`;
|
||||
// Coordinate text at high zoom – text shadow for readability on tile images
|
||||
if (cellSize >= 60) {
|
||||
const fontSize = Math.max(8, cellSize * 0.18);
|
||||
ctx.font = `${fontSize}px sans-serif`;
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillStyle = "rgba(0,0,0,0.6)";
|
||||
ctx.fillText(`${tile.x},${tile.y}`, px + 3, py + 3);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.75)";
|
||||
ctx.fillText(`${tile.x},${tile.y}`, px + 2, py + 2);
|
||||
}
|
||||
|
||||
// Tile labels at very high zoom
|
||||
if (cellSize >= 50 && (tile.name || tile.content?.code)) {
|
||||
ctx.fillStyle = "rgba(255,255,255,0.85)";
|
||||
ctx.font = `bold ${Math.max(9, cellSize * 0.18)}px sans-serif`;
|
||||
if (cellSize >= 80 && (tile.name || tile.content?.code)) {
|
||||
const nameSize = Math.max(9, cellSize * 0.16);
|
||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "bottom";
|
||||
if (tile.name) {
|
||||
// Text with shadow
|
||||
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
||||
ctx.fillText(tile.name, px + cellSize / 2 + 1, py + cellSize - 1);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.9)";
|
||||
ctx.fillText(tile.name, px + cellSize / 2, py + cellSize - 2);
|
||||
}
|
||||
if (tile.content?.code) {
|
||||
ctx.font = `${Math.max(8, cellSize * 0.15)}px sans-serif`;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.65)";
|
||||
ctx.textBaseline = "bottom";
|
||||
const codeSize = Math.max(8, cellSize * 0.13);
|
||||
ctx.font = `${codeSize}px sans-serif`;
|
||||
ctx.fillStyle = "rgba(0,0,0,0.6)";
|
||||
ctx.fillText(
|
||||
tile.content.code,
|
||||
px + cellSize / 2 + 1,
|
||||
py + cellSize - 1 - Math.max(10, cellSize * 0.18)
|
||||
);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.75)";
|
||||
ctx.fillText(
|
||||
tile.content.code,
|
||||
px + cellSize / 2,
|
||||
py + cellSize - 2 - Math.max(10, cellSize * 0.2)
|
||||
py + cellSize - 2 - Math.max(10, cellSize * 0.18)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -412,12 +422,15 @@ export default function MapPage() {
|
|||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
// Label with shadow for readability on tile images
|
||||
if (cellSize >= 14) {
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.font = `${Math.max(9, cellSize * 0.5)}px sans-serif`;
|
||||
const labelFont = `bold ${Math.max(9, cellSize * 0.4)}px sans-serif`;
|
||||
ctx.font = labelFont;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "bottom";
|
||||
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
||||
ctx.fillText(char.name, px + 1, py - radius - 2);
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillText(char.name, px, py - radius - 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -733,7 +746,7 @@ export default function MapPage() {
|
|||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.max(0.3, Math.min(5, zoom * factor));
|
||||
const newZoom = Math.max(0.1, Math.min(5, zoom * factor));
|
||||
const scale = newZoom / zoom;
|
||||
|
||||
// Adjust offset so the point under cursor stays fixed
|
||||
|
|
@ -745,7 +758,7 @@ export default function MapPage() {
|
|||
}
|
||||
|
||||
function resetView() {
|
||||
setZoom(1);
|
||||
setZoom(0.5);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
}
|
||||
|
||||
|
|
@ -778,7 +791,7 @@ export default function MapPage() {
|
|||
break;
|
||||
case "-":
|
||||
e.preventDefault();
|
||||
setZoom((z) => Math.max(0.3, z * 0.87));
|
||||
setZoom((z) => Math.max(0.1, z * 0.87));
|
||||
break;
|
||||
case "0":
|
||||
e.preventDefault();
|
||||
|
|
@ -965,7 +978,7 @@ export default function MapPage() {
|
|||
<Button
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
onClick={() => setZoom((z) => Math.max(0.3, z * 0.83))}
|
||||
onClick={() => setZoom((z) => Math.max(0.1, z * 0.83))}
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getEvents, getEventHistory } from "@/lib/api-client";
|
||||
import type { GameEvent } from "@/lib/types";
|
||||
import type { ActiveGameEvent, HistoricalEvent } from "@/lib/types";
|
||||
|
||||
export function useEvents() {
|
||||
return useQuery<GameEvent[]>({
|
||||
return useQuery<ActiveGameEvent[]>({
|
||||
queryKey: ["events"],
|
||||
queryFn: getEvents,
|
||||
refetchInterval: 10000,
|
||||
|
|
@ -13,7 +13,7 @@ export function useEvents() {
|
|||
}
|
||||
|
||||
export function useEventHistory() {
|
||||
return useQuery<GameEvent[]>({
|
||||
return useQuery<HistoricalEvent[]>({
|
||||
queryKey: ["events", "history"],
|
||||
queryFn: getEventHistory,
|
||||
refetchInterval: 30000,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getExchangeOrders,
|
||||
getMyOrders,
|
||||
getExchangeHistory,
|
||||
getSellHistory,
|
||||
getPriceHistory,
|
||||
} from "@/lib/api-client";
|
||||
import type { GEOrder, PricePoint } from "@/lib/types";
|
||||
import type { GEOrder, GEHistoryEntry, PricePoint } from "@/lib/types";
|
||||
|
||||
export function useExchangeOrders() {
|
||||
return useQuery<GEOrder[]>({
|
||||
|
|
@ -16,14 +18,31 @@ export function useExchangeOrders() {
|
|||
});
|
||||
}
|
||||
|
||||
export function useExchangeHistory() {
|
||||
export function useMyOrders() {
|
||||
return useQuery<GEOrder[]>({
|
||||
queryKey: ["exchange", "my-orders"],
|
||||
queryFn: getMyOrders,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useExchangeHistory() {
|
||||
return useQuery<GEHistoryEntry[]>({
|
||||
queryKey: ["exchange", "history"],
|
||||
queryFn: getExchangeHistory,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSellHistory(itemCode: string) {
|
||||
return useQuery<GEHistoryEntry[]>({
|
||||
queryKey: ["exchange", "sell-history", itemCode],
|
||||
queryFn: () => getSellHistory(itemCode),
|
||||
enabled: !!itemCode,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePriceHistory(itemCode: string) {
|
||||
return useQuery<PricePoint[]>({
|
||||
queryKey: ["exchange", "prices", itemCode],
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ import type {
|
|||
AutomationLog,
|
||||
AutomationStatus,
|
||||
GEOrder,
|
||||
GEHistoryEntry,
|
||||
PricePoint,
|
||||
GameEvent,
|
||||
ActiveGameEvent,
|
||||
HistoricalEvent,
|
||||
ActionLog,
|
||||
AnalyticsData,
|
||||
} from "./types";
|
||||
|
|
@ -224,8 +226,20 @@ export async function getExchangeOrders(): Promise<GEOrder[]> {
|
|||
return res.orders;
|
||||
}
|
||||
|
||||
export async function getExchangeHistory(): Promise<GEOrder[]> {
|
||||
const res = await fetchApi<{ history: GEOrder[] }>("/api/exchange/history");
|
||||
export async function getMyOrders(): Promise<GEOrder[]> {
|
||||
const res = await fetchApi<{ orders: GEOrder[] }>("/api/exchange/my-orders");
|
||||
return res.orders;
|
||||
}
|
||||
|
||||
export async function getExchangeHistory(): Promise<GEHistoryEntry[]> {
|
||||
const res = await fetchApi<{ history: GEHistoryEntry[] }>("/api/exchange/history");
|
||||
return res.history;
|
||||
}
|
||||
|
||||
export async function getSellHistory(itemCode: string): Promise<GEHistoryEntry[]> {
|
||||
const res = await fetchApi<{ history: GEHistoryEntry[] }>(
|
||||
`/api/exchange/sell-history/${encodeURIComponent(itemCode)}`
|
||||
);
|
||||
return res.history;
|
||||
}
|
||||
|
||||
|
|
@ -238,13 +252,13 @@ export async function getPriceHistory(itemCode: string): Promise<PricePoint[]> {
|
|||
|
||||
// ---------- Events API ----------
|
||||
|
||||
export async function getEvents(): Promise<GameEvent[]> {
|
||||
const res = await fetchApi<{ events: GameEvent[] }>("/api/events");
|
||||
export async function getEvents(): Promise<ActiveGameEvent[]> {
|
||||
const res = await fetchApi<{ events: ActiveGameEvent[] }>("/api/events");
|
||||
return res.events;
|
||||
}
|
||||
|
||||
export async function getEventHistory(): Promise<GameEvent[]> {
|
||||
const res = await fetchApi<{ events: GameEvent[] }>("/api/events/history");
|
||||
export async function getEventHistory(): Promise<HistoricalEvent[]> {
|
||||
const res = await fetchApi<{ events: HistoricalEvent[] }>("/api/events/history");
|
||||
return res.events;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -257,11 +257,22 @@ export interface GEOrder {
|
|||
id: string;
|
||||
code: string;
|
||||
type: "buy" | "sell";
|
||||
account?: string | null;
|
||||
price: number;
|
||||
quantity: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GEHistoryEntry {
|
||||
order_id: string;
|
||||
seller: string;
|
||||
buyer: string;
|
||||
code: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
sold_at: string;
|
||||
}
|
||||
|
||||
export interface PricePoint {
|
||||
item_code: string;
|
||||
buy_price: number;
|
||||
|
|
@ -272,6 +283,26 @@ export interface PricePoint {
|
|||
|
||||
// ---------- Event Types ----------
|
||||
|
||||
export interface ActiveGameEvent {
|
||||
name: string;
|
||||
code: string;
|
||||
content: { type: string; code: string };
|
||||
maps: { map_id: number; x: number; y: number; layer: string; skin: string }[];
|
||||
duration: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export interface HistoricalEvent {
|
||||
id: number;
|
||||
event_type: string;
|
||||
event_data: Record<string, unknown>;
|
||||
character_name?: string;
|
||||
map_x?: number;
|
||||
map_y?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** @deprecated Use ActiveGameEvent or HistoricalEvent */
|
||||
export interface GameEvent {
|
||||
id?: string;
|
||||
type: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue