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:
Paweł Orzech 2026-03-01 20:38:19 +01:00
parent 10781c7987
commit 2484a40dbd
No known key found for this signature in database
19 changed files with 2081 additions and 257 deletions

View file

@ -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:

View file

@ -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)
result = await db.execute(stmt)
rows = result.all()
return { return {
"character": character, "logs": [
"logs": [], # The API doesn't have a dedicated logs endpoint per character;
# action data comes from the automation logs in our DB
"character_data": {
"name": char_data.name,
"level": char_data.level,
"xp": char_data.xp,
"gold": char_data.gold,
"x": char_data.x,
"y": char_data.y,
"task": char_data.task,
"task_progress": char_data.task_progress,
"task_total": char_data.task_total,
},
}
else:
# Get all characters as a summary
characters = await client.get_characters()
return {
"characters": [
{ {
"name": c.name, "id": row.id,
"level": c.level, "character_name": row.character_name,
"xp": c.xp, "action_type": row.action_type,
"gold": c.gold, "details": row.details,
"x": c.x, "success": row.success,
"y": c.y, "created_at": row.created_at.isoformat(),
} }
for c in characters for row in rows
], ],
} }
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")

View file

@ -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

View file

@ -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

View file

@ -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

View 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"

View 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

View 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)"

View 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"

View 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

View 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

View file

@ -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:

View file

@ -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>{formatRelativeTime(expiry)}</span> <span>{formatDuration(event.duration)}</span>
</div> </div>
)}
</div> </div>
<p className="text-sm text-foreground">{getEventDescription(event)}</p> <p className="text-sm font-medium text-foreground">{event.name}</p>
<p className="text-xs text-muted-foreground capitalize">
{event.content.code.replaceAll("_", " ")}
</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground"> {locations.length > 0 && (
{location && ( <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1"> {locations.map((m) => (
<div key={m.map_id} className="flex items-center gap-1">
<MapPin className="size-3" /> <MapPin className="size-3" />
<span>{location}</span> <span>({m.x}, {m.y})</span>
</div>
))}
{event.maps.length > 3 && (
<span className="text-muted-foreground/60">
+{event.maps.length - 3} more
</span>
)}
</div> </div>
)} )}
<div className="flex items-center gap-1">
<CalendarDays className="size-3" />
<span>{formatDate(event.created_at)}</span>
</div>
</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>

View file

@ -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>

View file

@ -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>

View file

@ -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,

View file

@ -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],

View file

@ -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;
} }

View file

@ -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;