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")
|
@router.get("/orders")
|
||||||
async def get_orders(request: Request) -> dict[str, Any]:
|
async def browse_orders(
|
||||||
"""Get all active Grand Exchange 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)
|
client = _get_client(request)
|
||||||
service = _get_exchange_service(request)
|
service = _get_exchange_service(request)
|
||||||
|
|
||||||
try:
|
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:
|
except HTTPStatusError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=exc.response.status_code,
|
status_code=exc.response.status_code,
|
||||||
|
|
@ -48,7 +69,7 @@ async def get_orders(request: Request) -> dict[str, Any]:
|
||||||
|
|
||||||
@router.get("/history")
|
@router.get("/history")
|
||||||
async def get_history(request: Request) -> dict[str, Any]:
|
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)
|
client = _get_client(request)
|
||||||
service = _get_exchange_service(request)
|
service = _get_exchange_service(request)
|
||||||
|
|
||||||
|
|
@ -63,13 +84,30 @@ async def get_history(request: Request) -> dict[str, Any]:
|
||||||
return {"history": history}
|
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}")
|
@router.get("/prices/{item_code}")
|
||||||
async def get_price_history(
|
async def get_price_history(
|
||||||
item_code: str,
|
item_code: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
days: int = Query(default=7, ge=1, le=90, description="Number of days of history"),
|
days: int = Query(default=7, ge=1, le=90, description="Number of days of history"),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Get price history for a specific item."""
|
"""Get locally captured price history for a specific item."""
|
||||||
service = _get_exchange_service(request)
|
service = _get_exchange_service(request)
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
async with async_session_factory() as db:
|
||||||
|
|
|
||||||
|
|
@ -3,76 +3,63 @@
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, Query, Request
|
||||||
from httpx import HTTPStatusError
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.database import async_session_factory
|
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.analytics_service import AnalyticsService
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||||
|
|
||||||
|
|
||||||
def _get_client(request: Request) -> ArtifactsClient:
|
|
||||||
return request.app.state.artifacts_client
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def get_character_logs(
|
async def get_logs(
|
||||||
request: Request,
|
|
||||||
character: str = Query(default="", description="Character name to filter 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"),
|
limit: int = Query(default=50, ge=1, le=200, description="Max entries to return"),
|
||||||
) -> dict[str, Any]:
|
) -> 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
|
Joins automation_logs -> automation_runs -> automation_configs
|
||||||
from the game server.
|
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:
|
if character:
|
||||||
# Get logs for a specific character
|
stmt = stmt.where(AutomationConfig.character_name == character)
|
||||||
char_data = await client.get_character(character)
|
|
||||||
return {
|
result = await db.execute(stmt)
|
||||||
"character": character,
|
rows = result.all()
|
||||||
"logs": [], # The API doesn't have a dedicated logs endpoint per character;
|
|
||||||
# action data comes from the automation logs in our DB
|
return {
|
||||||
"character_data": {
|
"logs": [
|
||||||
"name": char_data.name,
|
{
|
||||||
"level": char_data.level,
|
"id": row.id,
|
||||||
"xp": char_data.xp,
|
"character_name": row.character_name,
|
||||||
"gold": char_data.gold,
|
"action_type": row.action_type,
|
||||||
"x": char_data.x,
|
"details": row.details,
|
||||||
"y": char_data.y,
|
"success": row.success,
|
||||||
"task": char_data.task,
|
"created_at": row.created_at.isoformat(),
|
||||||
"task_progress": char_data.task_progress,
|
|
||||||
"task_total": char_data.task_total,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
else:
|
for row in rows
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/analytics")
|
@router.get("/analytics")
|
||||||
|
|
|
||||||
|
|
@ -203,13 +203,16 @@ class ArtifactsClient:
|
||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
page_size: int = 100,
|
page_size: int = 100,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Fetch all pages from a paginated endpoint."""
|
"""Fetch all pages from a paginated endpoint."""
|
||||||
all_items: list[dict[str, Any]] = []
|
all_items: list[dict[str, Any]] = []
|
||||||
page = 1
|
page = 1
|
||||||
|
base_params = dict(params) if params else {}
|
||||||
|
|
||||||
while True:
|
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", [])
|
data = result.get("data", [])
|
||||||
all_items.extend(data)
|
all_items.extend(data)
|
||||||
|
|
||||||
|
|
@ -311,13 +314,30 @@ class ArtifactsClient:
|
||||||
result = await self._get("/my/bank")
|
result = await self._get("/my/bank")
|
||||||
return result.get("data", {})
|
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]]:
|
async def get_ge_orders(self) -> list[dict[str, Any]]:
|
||||||
result = await self._get("/my/grandexchange/orders")
|
"""Get the authenticated account's own active GE orders."""
|
||||||
return result.get("data", [])
|
return await self._get_paginated("/my/grandexchange/orders")
|
||||||
|
|
||||||
async def get_ge_history(self) -> list[dict[str, Any]]:
|
async def get_ge_history(self) -> list[dict[str, Any]]:
|
||||||
result = await self._get("/my/grandexchange/history")
|
"""Get the authenticated account's GE transaction history."""
|
||||||
return result.get("data", [])
|
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
|
# Action endpoints
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,22 @@ class ExchangeService:
|
||||||
# Order and history queries (pass-through to API with enrichment)
|
# Order and history queries (pass-through to API with enrichment)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def get_orders(self, client: ArtifactsClient) -> list[dict[str, Any]]:
|
async def browse_orders(
|
||||||
"""Get all active GE orders for the account.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
|
|
@ -45,6 +59,17 @@ class ExchangeService:
|
||||||
"""
|
"""
|
||||||
return await client.get_ge_history()
|
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
|
# Price capture
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -63,7 +88,7 @@ class ExchangeService:
|
||||||
Number of price entries captured.
|
Number of price entries captured.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
orders = await client.get_ge_orders()
|
orders = await client.browse_ge_orders()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to fetch GE orders for price capture")
|
logger.exception("Failed to fetch GE orders for price capture")
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -88,7 +113,7 @@ class ExchangeService:
|
||||||
|
|
||||||
price = order.get("price", 0)
|
price = order.get("price", 0)
|
||||||
quantity = order.get("quantity", 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
|
item_prices[code]["volume"] += quantity
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ from app.engine.pathfinder import Pathfinder
|
||||||
from app.schemas.game import (
|
from app.schemas.game import (
|
||||||
CharacterSchema,
|
CharacterSchema,
|
||||||
ContentSchema,
|
ContentSchema,
|
||||||
|
CraftItem,
|
||||||
|
CraftSchema,
|
||||||
|
EffectSchema,
|
||||||
InventorySlot,
|
InventorySlot,
|
||||||
|
ItemSchema,
|
||||||
MapSchema,
|
MapSchema,
|
||||||
MonsterSchema,
|
MonsterSchema,
|
||||||
ResourceSchema,
|
ResourceSchema,
|
||||||
|
|
@ -118,3 +122,37 @@ def pathfinder_with_maps(make_map_tile):
|
||||||
return pf
|
return pf
|
||||||
|
|
||||||
return _factory
|
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
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: prod
|
target: prod
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: always
|
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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ import {
|
||||||
Clock,
|
Clock,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Swords,
|
||||||
|
Trees,
|
||||||
} from "lucide-react";
|
} 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 { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -20,10 +22,18 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useEvents, useEventHistory } from "@/hooks/use-events";
|
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 {
|
function formatDate(dateStr: string): string {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return "-";
|
||||||
return date.toLocaleDateString([], {
|
return date.toLocaleDateString([], {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|
@ -32,111 +42,86 @@ function formatDate(dateStr: string): string {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string): string {
|
const CONTENT_TYPE_STYLES: Record<string, { icon: typeof Swords; color: string }> = {
|
||||||
const now = Date.now();
|
monster: { icon: Swords, color: "text-red-400 border-red-500/30" },
|
||||||
const then = new Date(dateStr).getTime();
|
resource: { icon: Trees, color: "text-green-400 border-green-500/30" },
|
||||||
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",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getEventTypeStyle(type: string): string {
|
function ActiveEventCard({ event }: { event: ActiveGameEvent }) {
|
||||||
return EVENT_TYPE_COLORS[type] ?? "text-muted-foreground border-border";
|
const style = CONTENT_TYPE_STYLES[event.content.type] ?? {
|
||||||
}
|
icon: Sparkles,
|
||||||
|
color: "text-muted-foreground border-border",
|
||||||
function ActiveEventCard({ event }: { event: GameEvent }) {
|
};
|
||||||
const location = getEventLocation(event);
|
const Icon = style.icon;
|
||||||
const expiry = getEventExpiry(event);
|
const locations = event.maps.slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="py-4">
|
<Card className="py-4">
|
||||||
<CardContent className="px-4 space-y-2">
|
<CardContent className="px-4 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="size-4 text-amber-400" />
|
<Icon className="size-4 text-amber-400" />
|
||||||
<Badge
|
<Badge variant="outline" className={`capitalize ${style.color}`}>
|
||||||
variant="outline"
|
{event.content.type}
|
||||||
className={`capitalize ${getEventTypeStyle(event.type)}`}
|
|
||||||
>
|
|
||||||
{event.type}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{expiry && (
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-1 text-xs text-amber-400">
|
<Clock className="size-3" />
|
||||||
<Clock className="size-3" />
|
<span>{formatDuration(event.duration)}</span>
|
||||||
<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>
|
</div>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</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() {
|
export default function EventsPage() {
|
||||||
const { data: activeEvents, isLoading: loadingActive, error } = useEvents();
|
const { data: activeEvents, isLoading: loadingActive, error } = useEvents();
|
||||||
const { data: historicalEvents, isLoading: loadingHistory } =
|
const { data: historicalEvents, isLoading: loadingHistory } =
|
||||||
|
|
@ -185,8 +170,8 @@ export default function EventsPage() {
|
||||||
|
|
||||||
{activeEvents && activeEvents.length > 0 && (
|
{activeEvents && activeEvents.length > 0 && (
|
||||||
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{activeEvents.map((event, idx) => (
|
{activeEvents.map((event) => (
|
||||||
<ActiveEventCard key={event.id ?? idx} event={event} />
|
<ActiveEventCard key={event.code} event={event} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -220,32 +205,14 @@ export default function EventsPage() {
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
<TableHead>Description</TableHead>
|
<TableHead>Character</TableHead>
|
||||||
<TableHead>Location</TableHead>
|
<TableHead>Location</TableHead>
|
||||||
<TableHead className="text-right">Date</TableHead>
|
<TableHead className="text-right">Date</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{sortedHistory.map((event, idx) => (
|
{sortedHistory.map((event) => (
|
||||||
<TableRow key={event.id ?? idx}>
|
<HistoryRow key={event.id} event={event} />
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
History,
|
History,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -29,12 +30,13 @@ import {
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import {
|
import {
|
||||||
useExchangeOrders,
|
useExchangeOrders,
|
||||||
|
useMyOrders,
|
||||||
useExchangeHistory,
|
useExchangeHistory,
|
||||||
usePriceHistory,
|
usePriceHistory,
|
||||||
} from "@/hooks/use-exchange";
|
} from "@/hooks/use-exchange";
|
||||||
import { PriceChart } from "@/components/exchange/price-chart";
|
import { PriceChart } from "@/components/exchange/price-chart";
|
||||||
import { GameIcon } from "@/components/ui/game-icon";
|
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 {
|
function formatDate(dateStr: string): string {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
|
|
@ -51,11 +53,13 @@ function OrdersTable({
|
||||||
isLoading,
|
isLoading,
|
||||||
search,
|
search,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
|
showAccount,
|
||||||
}: {
|
}: {
|
||||||
orders: GEOrder[];
|
orders: GEOrder[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
|
showAccount?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search.trim()) return orders;
|
if (!search.trim()) return orders;
|
||||||
|
|
@ -88,6 +92,7 @@ function OrdersTable({
|
||||||
<TableHead>Type</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
<TableHead className="text-right">Price</TableHead>
|
<TableHead className="text-right">Price</TableHead>
|
||||||
<TableHead className="text-right">Quantity</TableHead>
|
<TableHead className="text-right">Quantity</TableHead>
|
||||||
|
{showAccount && <TableHead>Account</TableHead>}
|
||||||
<TableHead className="text-right">Created</TableHead>
|
<TableHead className="text-right">Created</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -118,6 +123,11 @@ function OrdersTable({
|
||||||
<TableCell className="text-right tabular-nums">
|
<TableCell className="text-right tabular-nums">
|
||||||
{order.quantity.toLocaleString()}
|
{order.quantity.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{showAccount && (
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{order.account ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell className="text-right text-muted-foreground text-sm">
|
<TableCell className="text-right text-muted-foreground text-sm">
|
||||||
{formatDate(order.created_at)}
|
{formatDate(order.created_at)}
|
||||||
</TableCell>
|
</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() {
|
export default function ExchangePage() {
|
||||||
const { data: orders, isLoading: loadingOrders, error: ordersError } = useExchangeOrders();
|
const { data: orders, isLoading: loadingOrders, error: ordersError } = useExchangeOrders();
|
||||||
|
const { data: myOrders, isLoading: loadingMyOrders } = useMyOrders();
|
||||||
const { data: history, isLoading: loadingHistory } = useExchangeHistory();
|
const { data: history, isLoading: loadingHistory } = useExchangeHistory();
|
||||||
|
|
||||||
const [marketSearch, setMarketSearch] = useState("");
|
const [marketSearch, setMarketSearch] = useState("");
|
||||||
|
|
@ -172,9 +252,13 @@ export default function ExchangePage() {
|
||||||
<ShoppingCart className="size-4" />
|
<ShoppingCart className="size-4" />
|
||||||
Market
|
Market
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="my-orders" className="gap-1.5">
|
||||||
|
<User className="size-4" />
|
||||||
|
My Orders
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="history" className="gap-1.5">
|
<TabsTrigger value="history" className="gap-1.5">
|
||||||
<History className="size-4" />
|
<History className="size-4" />
|
||||||
My Orders
|
Trade History
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="prices" className="gap-1.5">
|
<TabsTrigger value="prices" className="gap-1.5">
|
||||||
<TrendingUp className="size-4" />
|
<TrendingUp className="size-4" />
|
||||||
|
|
@ -182,7 +266,7 @@ export default function ExchangePage() {
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Market Tab */}
|
{/* Market Tab - Browse all public orders */}
|
||||||
<TabsContent value="market" className="space-y-4">
|
<TabsContent value="market" className="space-y-4">
|
||||||
<div className="relative max-w-sm">
|
<div className="relative max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
<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 ?? []}
|
orders={orders ?? []}
|
||||||
isLoading={loadingOrders}
|
isLoading={loadingOrders}
|
||||||
search={marketSearch}
|
search={marketSearch}
|
||||||
|
showAccount
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
marketSearch.trim()
|
marketSearch.trim()
|
||||||
? `No orders found for "${marketSearch}"`
|
? `No orders found for "${marketSearch}"`
|
||||||
|
|
@ -208,13 +293,24 @@ export default function ExchangePage() {
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* My Orders Tab */}
|
{/* My Orders Tab - User's own active orders */}
|
||||||
<TabsContent value="history" className="space-y-4">
|
<TabsContent value="my-orders" className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<OrdersTable
|
<OrdersTable
|
||||||
orders={history ?? []}
|
orders={myOrders ?? []}
|
||||||
isLoading={loadingHistory}
|
isLoading={loadingMyOrders}
|
||||||
search=""
|
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."
|
emptyMessage="No transaction history found."
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,7 @@ const CONTENT_COLORS: Record<string, string> = {
|
||||||
|
|
||||||
const EMPTY_COLOR = "#1f2937";
|
const EMPTY_COLOR = "#1f2937";
|
||||||
const CHARACTER_COLOR = "#facc15";
|
const CHARACTER_COLOR = "#facc15";
|
||||||
const GRID_LINE_COLOR = "#374151";
|
const BASE_CELL_SIZE = 40;
|
||||||
const BASE_CELL_SIZE = 18;
|
|
||||||
const IMAGE_BASE = "https://artifactsmmo.com/images";
|
const IMAGE_BASE = "https://artifactsmmo.com/images";
|
||||||
|
|
||||||
const LEGEND_ITEMS = [
|
const LEGEND_ITEMS = [
|
||||||
|
|
@ -83,7 +82,8 @@ function loadImage(url: string): Promise<HTMLImageElement> {
|
||||||
if (cached) return Promise.resolve(cached);
|
if (cached) return Promise.resolve(cached);
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
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 = () => {
|
img.onload = () => {
|
||||||
imageCache.set(url, img);
|
imageCache.set(url, img);
|
||||||
resolve(img);
|
resolve(img);
|
||||||
|
|
@ -147,7 +147,7 @@ export default function MapPage() {
|
||||||
const rafRef = useRef<number>(0);
|
const rafRef = useRef<number>(0);
|
||||||
const dragDistRef = useRef(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 [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
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)
|
if (px + cellSize < 0 || py + cellSize < 0 || px > width || py > height)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Try to draw skin image
|
// Draw skin image – seamless tiles, no gaps
|
||||||
const skinImg = tile.skin ? imageCache.get(skinUrl(tile.skin)) : null;
|
const skinImg = tile.skin ? imageCache.get(skinUrl(tile.skin)) : null;
|
||||||
|
|
||||||
if (skinImg) {
|
if (skinImg) {
|
||||||
ctx.drawImage(skinImg, px, py, cellSize, cellSize);
|
ctx.drawImage(skinImg, px, py, cellSize, cellSize);
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = getTileColor(tile);
|
// Fallback: dark ground color for tiles without skin images
|
||||||
ctx.fillRect(px, py, cellSize - 1, cellSize - 1);
|
ctx.fillStyle = EMPTY_COLOR;
|
||||||
|
ctx.fillRect(px, py, cellSize, cellSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content icon overlay
|
// Content icon overlay
|
||||||
|
|
@ -315,70 +316,79 @@ export default function MapPage() {
|
||||||
const iconSize = cellSize * 0.6;
|
const iconSize = cellSize * 0.6;
|
||||||
const iconX = px + (cellSize - iconSize) / 2;
|
const iconX = px + (cellSize - iconSize) / 2;
|
||||||
const iconY = py + (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.drawImage(iconImg, iconX, iconY, iconSize, iconSize);
|
||||||
|
ctx.shadowColor = "transparent";
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
}
|
}
|
||||||
} else if (!skinImg) {
|
} else {
|
||||||
// For bank/workshop/etc without skin, draw a colored indicator dot
|
// For bank/workshop/etc – small colored badge in bottom-right
|
||||||
const dotRadius = Math.max(2, cellSize * 0.15);
|
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.beginPath();
|
||||||
ctx.arc(
|
ctx.arc(badgeX + badgeSize / 2, badgeY + badgeSize / 2, badgeSize / 2 + 1, 0, Math.PI * 2);
|
||||||
px + cellSize / 2,
|
ctx.fill();
|
||||||
py + cellSize / 2,
|
|
||||||
dotRadius,
|
|
||||||
0,
|
|
||||||
Math.PI * 2
|
|
||||||
);
|
|
||||||
ctx.fillStyle = CONTENT_COLORS[tile.content.type] ?? "#fff";
|
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();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid lines (subtler when images are shown)
|
// Selected tile highlight – bright outline
|
||||||
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
|
|
||||||
if (
|
if (
|
||||||
selectedTile &&
|
selectedTile &&
|
||||||
tile.x === selectedTile.tile.x &&
|
tile.x === selectedTile.tile.x &&
|
||||||
tile.y === selectedTile.tile.y
|
tile.y === selectedTile.tile.y
|
||||||
) {
|
) {
|
||||||
ctx.strokeStyle = "#3b82f6";
|
ctx.strokeStyle = "#3b82f6";
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2.5;
|
||||||
ctx.strokeRect(px + 1, py + 1, cellSize - 3, cellSize - 3);
|
ctx.strokeRect(px + 1, py + 1, cellSize - 2, cellSize - 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coordinate text at high zoom
|
// Coordinate text at high zoom – text shadow for readability on tile images
|
||||||
if (cellSize >= 40) {
|
if (cellSize >= 60) {
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.6)";
|
const fontSize = Math.max(8, cellSize * 0.18);
|
||||||
ctx.font = `${Math.max(8, cellSize * 0.2)}px sans-serif`;
|
ctx.font = `${fontSize}px sans-serif`;
|
||||||
ctx.textAlign = "left";
|
ctx.textAlign = "left";
|
||||||
ctx.textBaseline = "top";
|
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);
|
ctx.fillText(`${tile.x},${tile.y}`, px + 2, py + 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tile labels at very high zoom
|
// Tile labels at very high zoom
|
||||||
if (cellSize >= 50 && (tile.name || tile.content?.code)) {
|
if (cellSize >= 80 && (tile.name || tile.content?.code)) {
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.85)";
|
const nameSize = Math.max(9, cellSize * 0.16);
|
||||||
ctx.font = `bold ${Math.max(9, cellSize * 0.18)}px sans-serif`;
|
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "bottom";
|
ctx.textBaseline = "bottom";
|
||||||
if (tile.name) {
|
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);
|
ctx.fillText(tile.name, px + cellSize / 2, py + cellSize - 2);
|
||||||
}
|
}
|
||||||
if (tile.content?.code) {
|
if (tile.content?.code) {
|
||||||
ctx.font = `${Math.max(8, cellSize * 0.15)}px sans-serif`;
|
const codeSize = Math.max(8, cellSize * 0.13);
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.65)";
|
ctx.font = `${codeSize}px sans-serif`;
|
||||||
ctx.textBaseline = "bottom";
|
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(
|
ctx.fillText(
|
||||||
tile.content.code,
|
tile.content.code,
|
||||||
px + cellSize / 2,
|
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.lineWidth = 1;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Label
|
// Label with shadow for readability on tile images
|
||||||
if (cellSize >= 14) {
|
if (cellSize >= 14) {
|
||||||
ctx.fillStyle = "#fff";
|
const labelFont = `bold ${Math.max(9, cellSize * 0.4)}px sans-serif`;
|
||||||
ctx.font = `${Math.max(9, cellSize * 0.5)}px sans-serif`;
|
ctx.font = labelFont;
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "bottom";
|
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);
|
ctx.fillText(char.name, px, py - radius - 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -733,7 +746,7 @@ export default function MapPage() {
|
||||||
const mouseY = e.clientY - rect.top;
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
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;
|
const scale = newZoom / zoom;
|
||||||
|
|
||||||
// Adjust offset so the point under cursor stays fixed
|
// Adjust offset so the point under cursor stays fixed
|
||||||
|
|
@ -745,7 +758,7 @@ export default function MapPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetView() {
|
function resetView() {
|
||||||
setZoom(1);
|
setZoom(0.5);
|
||||||
setOffset({ x: 0, y: 0 });
|
setOffset({ x: 0, y: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -778,7 +791,7 @@ export default function MapPage() {
|
||||||
break;
|
break;
|
||||||
case "-":
|
case "-":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setZoom((z) => Math.max(0.3, z * 0.87));
|
setZoom((z) => Math.max(0.1, z * 0.87));
|
||||||
break;
|
break;
|
||||||
case "0":
|
case "0":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -965,7 +978,7 @@ export default function MapPage() {
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="secondary"
|
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" />
|
<Minus className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getEvents, getEventHistory } from "@/lib/api-client";
|
import { getEvents, getEventHistory } from "@/lib/api-client";
|
||||||
import type { GameEvent } from "@/lib/types";
|
import type { ActiveGameEvent, HistoricalEvent } from "@/lib/types";
|
||||||
|
|
||||||
export function useEvents() {
|
export function useEvents() {
|
||||||
return useQuery<GameEvent[]>({
|
return useQuery<ActiveGameEvent[]>({
|
||||||
queryKey: ["events"],
|
queryKey: ["events"],
|
||||||
queryFn: getEvents,
|
queryFn: getEvents,
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
|
|
@ -13,7 +13,7 @@ export function useEvents() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEventHistory() {
|
export function useEventHistory() {
|
||||||
return useQuery<GameEvent[]>({
|
return useQuery<HistoricalEvent[]>({
|
||||||
queryKey: ["events", "history"],
|
queryKey: ["events", "history"],
|
||||||
queryFn: getEventHistory,
|
queryFn: getEventHistory,
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getExchangeOrders,
|
getExchangeOrders,
|
||||||
|
getMyOrders,
|
||||||
getExchangeHistory,
|
getExchangeHistory,
|
||||||
|
getSellHistory,
|
||||||
getPriceHistory,
|
getPriceHistory,
|
||||||
} from "@/lib/api-client";
|
} from "@/lib/api-client";
|
||||||
import type { GEOrder, PricePoint } from "@/lib/types";
|
import type { GEOrder, GEHistoryEntry, PricePoint } from "@/lib/types";
|
||||||
|
|
||||||
export function useExchangeOrders() {
|
export function useExchangeOrders() {
|
||||||
return useQuery<GEOrder[]>({
|
return useQuery<GEOrder[]>({
|
||||||
|
|
@ -16,14 +18,31 @@ export function useExchangeOrders() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useExchangeHistory() {
|
export function useMyOrders() {
|
||||||
return useQuery<GEOrder[]>({
|
return useQuery<GEOrder[]>({
|
||||||
|
queryKey: ["exchange", "my-orders"],
|
||||||
|
queryFn: getMyOrders,
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExchangeHistory() {
|
||||||
|
return useQuery<GEHistoryEntry[]>({
|
||||||
queryKey: ["exchange", "history"],
|
queryKey: ["exchange", "history"],
|
||||||
queryFn: getExchangeHistory,
|
queryFn: getExchangeHistory,
|
||||||
refetchInterval: 30000,
|
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) {
|
export function usePriceHistory(itemCode: string) {
|
||||||
return useQuery<PricePoint[]>({
|
return useQuery<PricePoint[]>({
|
||||||
queryKey: ["exchange", "prices", itemCode],
|
queryKey: ["exchange", "prices", itemCode],
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@ import type {
|
||||||
AutomationLog,
|
AutomationLog,
|
||||||
AutomationStatus,
|
AutomationStatus,
|
||||||
GEOrder,
|
GEOrder,
|
||||||
|
GEHistoryEntry,
|
||||||
PricePoint,
|
PricePoint,
|
||||||
GameEvent,
|
ActiveGameEvent,
|
||||||
|
HistoricalEvent,
|
||||||
ActionLog,
|
ActionLog,
|
||||||
AnalyticsData,
|
AnalyticsData,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
@ -224,8 +226,20 @@ export async function getExchangeOrders(): Promise<GEOrder[]> {
|
||||||
return res.orders;
|
return res.orders;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getExchangeHistory(): Promise<GEOrder[]> {
|
export async function getMyOrders(): Promise<GEOrder[]> {
|
||||||
const res = await fetchApi<{ history: GEOrder[] }>("/api/exchange/history");
|
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;
|
return res.history;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,13 +252,13 @@ export async function getPriceHistory(itemCode: string): Promise<PricePoint[]> {
|
||||||
|
|
||||||
// ---------- Events API ----------
|
// ---------- Events API ----------
|
||||||
|
|
||||||
export async function getEvents(): Promise<GameEvent[]> {
|
export async function getEvents(): Promise<ActiveGameEvent[]> {
|
||||||
const res = await fetchApi<{ events: GameEvent[] }>("/api/events");
|
const res = await fetchApi<{ events: ActiveGameEvent[] }>("/api/events");
|
||||||
return res.events;
|
return res.events;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEventHistory(): Promise<GameEvent[]> {
|
export async function getEventHistory(): Promise<HistoricalEvent[]> {
|
||||||
const res = await fetchApi<{ events: GameEvent[] }>("/api/events/history");
|
const res = await fetchApi<{ events: HistoricalEvent[] }>("/api/events/history");
|
||||||
return res.events;
|
return res.events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -257,11 +257,22 @@ export interface GEOrder {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
type: "buy" | "sell";
|
type: "buy" | "sell";
|
||||||
|
account?: string | null;
|
||||||
price: number;
|
price: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
created_at: string;
|
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 {
|
export interface PricePoint {
|
||||||
item_code: string;
|
item_code: string;
|
||||||
buy_price: number;
|
buy_price: number;
|
||||||
|
|
@ -272,6 +283,26 @@ export interface PricePoint {
|
||||||
|
|
||||||
// ---------- Event Types ----------
|
// ---------- 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 {
|
export interface GameEvent {
|
||||||
id?: string;
|
id?: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue