From 2484a40dbd8340f5ee6849fe0ce47e14b4a5447f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Sun, 1 Mar 2026 20:38:19 +0100 Subject: [PATCH] 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 --- backend/app/api/exchange.py | 48 ++- backend/app/api/logs.py | 91 +++--- backend/app/services/artifacts_client.py | 30 +- backend/app/services/exchange_service.py | 33 ++- backend/tests/conftest.py | 38 +++ backend/tests/test_base_strategy.py | 109 +++++++ backend/tests/test_crafting_strategy.py | 304 +++++++++++++++++++ backend/tests/test_equipment_optimizer.py | 226 ++++++++++++++ backend/tests/test_leveling_strategy.py | 345 ++++++++++++++++++++++ backend/tests/test_task_strategy.py | 314 ++++++++++++++++++++ backend/tests/test_trading_strategy.py | 288 ++++++++++++++++++ docker-compose.prod.yml | 14 +- frontend/src/app/events/page.tsx | 193 +++++------- frontend/src/app/exchange/page.tsx | 110 ++++++- frontend/src/app/map/page.tsx | 107 ++++--- frontend/src/hooks/use-events.ts | 6 +- frontend/src/hooks/use-exchange.ts | 23 +- frontend/src/lib/api-client.ts | 28 +- frontend/src/lib/types.ts | 31 ++ 19 files changed, 2081 insertions(+), 257 deletions(-) create mode 100644 backend/tests/test_base_strategy.py create mode 100644 backend/tests/test_crafting_strategy.py create mode 100644 backend/tests/test_equipment_optimizer.py create mode 100644 backend/tests/test_leveling_strategy.py create mode 100644 backend/tests/test_task_strategy.py create mode 100644 backend/tests/test_trading_strategy.py diff --git a/backend/app/api/exchange.py b/backend/app/api/exchange.py index 1ff178f..f8c04ef 100644 --- a/backend/app/api/exchange.py +++ b/backend/app/api/exchange.py @@ -30,13 +30,34 @@ def _get_exchange_service(request: Request) -> ExchangeService: @router.get("/orders") -async def get_orders(request: Request) -> dict[str, Any]: - """Get all active Grand Exchange orders.""" +async def browse_orders( + request: Request, + code: str | None = Query(default=None, description="Filter by item code"), + type: str | None = Query(default=None, description="Filter by order type (sell or buy)"), +) -> dict[str, Any]: + """Browse all active Grand Exchange orders (public market data).""" client = _get_client(request) service = _get_exchange_service(request) try: - orders = await service.get_orders(client) + orders = await service.browse_orders(client, code=code, order_type=type) + except HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Artifacts API error: {exc.response.text}", + ) from exc + + return {"orders": orders} + + +@router.get("/my-orders") +async def get_my_orders(request: Request) -> dict[str, Any]: + """Get the authenticated account's own active GE orders.""" + client = _get_client(request) + service = _get_exchange_service(request) + + try: + orders = await service.get_my_orders(client) except HTTPStatusError as exc: raise HTTPException( status_code=exc.response.status_code, @@ -48,7 +69,7 @@ async def get_orders(request: Request) -> dict[str, Any]: @router.get("/history") async def get_history(request: Request) -> dict[str, Any]: - """Get Grand Exchange transaction history.""" + """Get the authenticated account's GE transaction history.""" client = _get_client(request) service = _get_exchange_service(request) @@ -63,13 +84,30 @@ async def get_history(request: Request) -> dict[str, Any]: return {"history": history} +@router.get("/sell-history/{item_code}") +async def get_sell_history(item_code: str, request: Request) -> dict[str, Any]: + """Get public sale history for a specific item (last 7 days from API).""" + client = _get_client(request) + service = _get_exchange_service(request) + + try: + history = await service.get_sell_history(client, item_code) + except HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Artifacts API error: {exc.response.text}", + ) from exc + + return {"item_code": item_code, "history": history} + + @router.get("/prices/{item_code}") async def get_price_history( item_code: str, request: Request, days: int = Query(default=7, ge=1, le=90, description="Number of days of history"), ) -> dict[str, Any]: - """Get price history for a specific item.""" + """Get locally captured price history for a specific item.""" service = _get_exchange_service(request) async with async_session_factory() as db: diff --git a/backend/app/api/logs.py b/backend/app/api/logs.py index 78c0066..0d13301 100644 --- a/backend/app/api/logs.py +++ b/backend/app/api/logs.py @@ -3,76 +3,63 @@ import logging from typing import Any -from fastapi import APIRouter, HTTPException, Query, Request -from httpx import HTTPStatusError +from fastapi import APIRouter, Query, Request +from sqlalchemy import select from app.database import async_session_factory +from app.models.automation import AutomationConfig, AutomationLog, AutomationRun from app.services.analytics_service import AnalyticsService -from app.services.artifacts_client import ArtifactsClient logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/logs", tags=["logs"]) -def _get_client(request: Request) -> ArtifactsClient: - return request.app.state.artifacts_client - - @router.get("/") -async def get_character_logs( - request: Request, +async def get_logs( character: str = Query(default="", description="Character name to filter logs"), limit: int = Query(default=50, ge=1, le=200, description="Max entries to return"), ) -> dict[str, Any]: - """Get character action logs from the Artifacts API. + """Get automation action logs from the database. - This endpoint retrieves the character's recent action logs directly - from the game server. + Joins automation_logs -> automation_runs -> automation_configs + to include character_name with each log entry. """ - client = _get_client(request) + async with async_session_factory() as db: + stmt = ( + select( + AutomationLog.id, + AutomationLog.action_type, + AutomationLog.details, + AutomationLog.success, + AutomationLog.created_at, + AutomationConfig.character_name, + ) + .join(AutomationRun, AutomationLog.run_id == AutomationRun.id) + .join(AutomationConfig, AutomationRun.config_id == AutomationConfig.id) + .order_by(AutomationLog.created_at.desc()) + .limit(limit) + ) - try: if character: - # Get logs for a specific character - char_data = await client.get_character(character) - return { - "character": character, - "logs": [], # The API doesn't have a dedicated logs endpoint per character; - # action data comes from the automation logs in our DB - "character_data": { - "name": char_data.name, - "level": char_data.level, - "xp": char_data.xp, - "gold": char_data.gold, - "x": char_data.x, - "y": char_data.y, - "task": char_data.task, - "task_progress": char_data.task_progress, - "task_total": char_data.task_total, - }, + stmt = stmt.where(AutomationConfig.character_name == character) + + result = await db.execute(stmt) + rows = result.all() + + return { + "logs": [ + { + "id": row.id, + "character_name": row.character_name, + "action_type": row.action_type, + "details": row.details, + "success": row.success, + "created_at": row.created_at.isoformat(), } - else: - # Get all characters as a summary - characters = await client.get_characters() - return { - "characters": [ - { - "name": c.name, - "level": c.level, - "xp": c.xp, - "gold": c.gold, - "x": c.x, - "y": c.y, - } - for c in characters - ], - } - except HTTPStatusError as exc: - raise HTTPException( - status_code=exc.response.status_code, - detail=f"Artifacts API error: {exc.response.text}", - ) from exc + for row in rows + ], + } @router.get("/analytics") diff --git a/backend/app/services/artifacts_client.py b/backend/app/services/artifacts_client.py index 9882625..4a02a94 100644 --- a/backend/app/services/artifacts_client.py +++ b/backend/app/services/artifacts_client.py @@ -203,13 +203,16 @@ class ArtifactsClient: self, path: str, page_size: int = 100, + params: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: """Fetch all pages from a paginated endpoint.""" all_items: list[dict[str, Any]] = [] page = 1 + base_params = dict(params) if params else {} while True: - result = await self._get(path, params={"page": page, "size": page_size}) + req_params = {**base_params, "page": page, "size": page_size} + result = await self._get(path, params=req_params) data = result.get("data", []) all_items.extend(data) @@ -311,13 +314,30 @@ class ArtifactsClient: result = await self._get("/my/bank") return result.get("data", {}) + async def browse_ge_orders( + self, + code: str | None = None, + order_type: str | None = None, + ) -> list[dict[str, Any]]: + """Browse ALL active Grand Exchange orders (public endpoint).""" + params: dict[str, Any] = {} + if code: + params["code"] = code + if order_type: + params["type"] = order_type + return await self._get_paginated("/grandexchange/orders", params=params) + async def get_ge_orders(self) -> list[dict[str, Any]]: - result = await self._get("/my/grandexchange/orders") - return result.get("data", []) + """Get the authenticated account's own active GE orders.""" + return await self._get_paginated("/my/grandexchange/orders") async def get_ge_history(self) -> list[dict[str, Any]]: - result = await self._get("/my/grandexchange/history") - return result.get("data", []) + """Get the authenticated account's GE transaction history.""" + return await self._get_paginated("/my/grandexchange/history") + + async def get_ge_sell_history(self, item_code: str) -> list[dict[str, Any]]: + """Get public sale history for a specific item (last 7 days).""" + return await self._get_paginated(f"/grandexchange/history/{item_code}") # ------------------------------------------------------------------ # Action endpoints diff --git a/backend/app/services/exchange_service.py b/backend/app/services/exchange_service.py index db0d041..552e5bc 100644 --- a/backend/app/services/exchange_service.py +++ b/backend/app/services/exchange_service.py @@ -27,8 +27,22 @@ class ExchangeService: # Order and history queries (pass-through to API with enrichment) # ------------------------------------------------------------------ - async def get_orders(self, client: ArtifactsClient) -> list[dict[str, Any]]: - """Get all active GE orders for the account. + async def browse_orders( + self, + client: ArtifactsClient, + code: str | None = None, + order_type: str | None = None, + ) -> list[dict[str, Any]]: + """Browse all active GE orders on the market (public). + + Returns + ------- + List of order dicts from the Artifacts API. + """ + return await client.browse_ge_orders(code=code, order_type=order_type) + + async def get_my_orders(self, client: ArtifactsClient) -> list[dict[str, Any]]: + """Get the authenticated account's own active GE orders. Returns ------- @@ -45,6 +59,17 @@ class ExchangeService: """ return await client.get_ge_history() + async def get_sell_history( + self, client: ArtifactsClient, item_code: str + ) -> list[dict[str, Any]]: + """Get public sale history for a specific item. + + Returns + ------- + List of sale history dicts from the Artifacts API. + """ + return await client.get_ge_sell_history(item_code) + # ------------------------------------------------------------------ # Price capture # ------------------------------------------------------------------ @@ -63,7 +88,7 @@ class ExchangeService: Number of price entries captured. """ try: - orders = await client.get_ge_orders() + orders = await client.browse_ge_orders() except Exception: logger.exception("Failed to fetch GE orders for price capture") return 0 @@ -88,7 +113,7 @@ class ExchangeService: price = order.get("price", 0) quantity = order.get("quantity", 0) - order_type = order.get("order", "") # "buy" or "sell" + order_type = order.get("type", "") # "buy" or "sell" item_prices[code]["volume"] += quantity diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fd97226..e4fc88b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,7 +6,11 @@ from app.engine.pathfinder import Pathfinder from app.schemas.game import ( CharacterSchema, ContentSchema, + CraftItem, + CraftSchema, + EffectSchema, InventorySlot, + ItemSchema, MapSchema, MonsterSchema, ResourceSchema, @@ -118,3 +122,37 @@ def pathfinder_with_maps(make_map_tile): return pf return _factory + + +@pytest.fixture +def make_item(): + """Factory fixture that returns an ItemSchema with sensible defaults.""" + + def _factory(**overrides) -> ItemSchema: + defaults = { + "name": "Iron Sword", + "code": "iron_sword", + "level": 10, + "type": "weapon", + "subtype": "sword", + "effects": [], + "craft": None, + } + defaults.update(overrides) + + # Convert raw effect dicts to EffectSchema instances if needed + raw_effects = defaults.get("effects", []) + if raw_effects and isinstance(raw_effects[0], dict): + defaults["effects"] = [EffectSchema(**e) for e in raw_effects] + + # Convert raw craft dict to CraftSchema if needed + raw_craft = defaults.get("craft") + if raw_craft and isinstance(raw_craft, dict): + if "items" in raw_craft and raw_craft["items"]: + if isinstance(raw_craft["items"][0], dict): + raw_craft["items"] = [CraftItem(**ci) for ci in raw_craft["items"]] + defaults["craft"] = CraftSchema(**raw_craft) + + return ItemSchema(**defaults) + + return _factory diff --git a/backend/tests/test_base_strategy.py b/backend/tests/test_base_strategy.py new file mode 100644 index 0000000..9bd9f7e --- /dev/null +++ b/backend/tests/test_base_strategy.py @@ -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" diff --git a/backend/tests/test_crafting_strategy.py b/backend/tests/test_crafting_strategy.py new file mode 100644 index 0000000..c54d496 --- /dev/null +++ b/backend/tests/test_crafting_strategy.py @@ -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 diff --git a/backend/tests/test_equipment_optimizer.py b/backend/tests/test_equipment_optimizer.py new file mode 100644 index 0000000..ccafb4f --- /dev/null +++ b/backend/tests/test_equipment_optimizer.py @@ -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)" diff --git a/backend/tests/test_leveling_strategy.py b/backend/tests/test_leveling_strategy.py new file mode 100644 index 0000000..4b75454 --- /dev/null +++ b/backend/tests/test_leveling_strategy.py @@ -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" diff --git a/backend/tests/test_task_strategy.py b/backend/tests/test_task_strategy.py new file mode 100644 index 0000000..73ebb10 --- /dev/null +++ b/backend/tests/test_task_strategy.py @@ -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 diff --git a/backend/tests/test_trading_strategy.py b/backend/tests/test_trading_strategy.py new file mode 100644 index 0000000..06180a3 --- /dev/null +++ b/backend/tests/test_trading_strategy.py @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0a50dd1..d5d6f02 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,23 +32,13 @@ services: context: ./frontend dockerfile: Dockerfile target: prod + expose: + - "3000" environment: - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} depends_on: - backend restart: always - nginx: - image: nginx:alpine - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro - depends_on: - - frontend - - backend - restart: always - volumes: pgdata: diff --git a/frontend/src/app/events/page.tsx b/frontend/src/app/events/page.tsx index c2dd084..5b313ff 100644 --- a/frontend/src/app/events/page.tsx +++ b/frontend/src/app/events/page.tsx @@ -8,8 +8,10 @@ import { Clock, CalendarDays, Sparkles, + Swords, + Trees, } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Table, @@ -20,10 +22,18 @@ import { TableRow, } from "@/components/ui/table"; import { useEvents, useEventHistory } from "@/hooks/use-events"; -import type { GameEvent } from "@/lib/types"; +import type { ActiveGameEvent, HistoricalEvent } from "@/lib/types"; + +function formatDuration(minutes: number): string { + if (minutes < 60) return `${minutes}m`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m > 0 ? `${h}h ${m}m` : `${h}h`; +} function formatDate(dateStr: string): string { const date = new Date(dateStr); + if (isNaN(date.getTime())) return "-"; return date.toLocaleDateString([], { month: "short", day: "numeric", @@ -32,111 +42,86 @@ function formatDate(dateStr: string): string { }); } -function formatRelativeTime(dateStr: string): string { - const now = Date.now(); - const then = new Date(dateStr).getTime(); - const diffMs = then - now; - - if (diffMs <= 0) return "Ended"; - - const diffS = Math.floor(diffMs / 1000); - if (diffS < 60) return `${diffS}s remaining`; - const diffM = Math.floor(diffS / 60); - if (diffM < 60) return `${diffM}m remaining`; - const diffH = Math.floor(diffM / 60); - if (diffH < 24) return `${diffH}h ${diffM % 60}m remaining`; - return `${Math.floor(diffH / 24)}d ${diffH % 24}h remaining`; -} - -function getEventDescription(event: GameEvent): string { - if (event.data.description && typeof event.data.description === "string") { - return event.data.description; - } - if (event.data.name && typeof event.data.name === "string") { - return event.data.name; - } - return "Game event"; -} - -function getEventLocation(event: GameEvent): string | null { - if (event.data.map && typeof event.data.map === "string") { - return event.data.map; - } - if ( - event.data.x !== undefined && - event.data.y !== undefined - ) { - return `(${event.data.x}, ${event.data.y})`; - } - return null; -} - -function getEventExpiry(event: GameEvent): string | null { - if (event.data.expiration && typeof event.data.expiration === "string") { - return event.data.expiration; - } - if (event.data.expires_at && typeof event.data.expires_at === "string") { - return event.data.expires_at; - } - return null; -} - -const EVENT_TYPE_COLORS: Record = { - portal: "text-purple-400 border-purple-500/30", - boss: "text-red-400 border-red-500/30", - resource: "text-green-400 border-green-500/30", - bonus: "text-amber-400 border-amber-500/30", - special: "text-cyan-400 border-cyan-500/30", +const CONTENT_TYPE_STYLES: Record = { + monster: { icon: Swords, color: "text-red-400 border-red-500/30" }, + resource: { icon: Trees, color: "text-green-400 border-green-500/30" }, }; -function getEventTypeStyle(type: string): string { - return EVENT_TYPE_COLORS[type] ?? "text-muted-foreground border-border"; -} - -function ActiveEventCard({ event }: { event: GameEvent }) { - const location = getEventLocation(event); - const expiry = getEventExpiry(event); +function ActiveEventCard({ event }: { event: ActiveGameEvent }) { + const style = CONTENT_TYPE_STYLES[event.content.type] ?? { + icon: Sparkles, + color: "text-muted-foreground border-border", + }; + const Icon = style.icon; + const locations = event.maps.slice(0, 3); return (
- - - {event.type} + + + {event.content.type}
- {expiry && ( -
- - {formatRelativeTime(expiry)} -
- )} -
- -

{getEventDescription(event)}

- -
- {location && ( -
- - {location} -
- )} -
- - {formatDate(event.created_at)} +
+ + {formatDuration(event.duration)}
+ +

{event.name}

+

+ {event.content.code.replaceAll("_", " ")} +

+ + {locations.length > 0 && ( +
+ {locations.map((m) => ( +
+ + ({m.x}, {m.y}) +
+ ))} + {event.maps.length > 3 && ( + + +{event.maps.length - 3} more + + )} +
+ )} ); } +function HistoryRow({ event }: { event: HistoricalEvent }) { + const location = + event.map_x !== undefined && event.map_y !== undefined + ? `(${event.map_x}, ${event.map_y})` + : "-"; + + return ( + + + + {event.event_type} + + + + {event.character_name ?? "-"} + + + {location} + + + {formatDate(event.created_at)} + + + ); +} + export default function EventsPage() { const { data: activeEvents, isLoading: loadingActive, error } = useEvents(); const { data: historicalEvents, isLoading: loadingHistory } = @@ -185,8 +170,8 @@ export default function EventsPage() { {activeEvents && activeEvents.length > 0 && (
- {activeEvents.map((event, idx) => ( - + {activeEvents.map((event) => ( + ))}
)} @@ -220,32 +205,14 @@ export default function EventsPage() { Type - Description + Character Location Date - {sortedHistory.map((event, idx) => ( - - - - {event.type} - - - - {getEventDescription(event)} - - - {getEventLocation(event) ?? "-"} - - - {formatDate(event.created_at)} - - + {sortedHistory.map((event) => ( + ))} diff --git a/frontend/src/app/exchange/page.tsx b/frontend/src/app/exchange/page.tsx index 3e4004f..9ab3bf0 100644 --- a/frontend/src/app/exchange/page.tsx +++ b/frontend/src/app/exchange/page.tsx @@ -8,6 +8,7 @@ import { ShoppingCart, History, TrendingUp, + User, } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -29,12 +30,13 @@ import { } from "@/components/ui/table"; import { useExchangeOrders, + useMyOrders, useExchangeHistory, usePriceHistory, } from "@/hooks/use-exchange"; import { PriceChart } from "@/components/exchange/price-chart"; import { GameIcon } from "@/components/ui/game-icon"; -import type { GEOrder } from "@/lib/types"; +import type { GEOrder, GEHistoryEntry } from "@/lib/types"; function formatDate(dateStr: string): string { const date = new Date(dateStr); @@ -51,11 +53,13 @@ function OrdersTable({ isLoading, search, emptyMessage, + showAccount, }: { orders: GEOrder[]; isLoading: boolean; search: string; emptyMessage: string; + showAccount?: boolean; }) { const filtered = useMemo(() => { if (!search.trim()) return orders; @@ -88,6 +92,7 @@ function OrdersTable({ Type Price Quantity + {showAccount && Account} Created @@ -118,6 +123,11 @@ function OrdersTable({ {order.quantity.toLocaleString()} + {showAccount && ( + + {order.account ?? "—"} + + )} {formatDate(order.created_at)} @@ -128,8 +138,78 @@ function OrdersTable({ ); } +function HistoryTable({ + entries, + isLoading, + emptyMessage, +}: { + entries: GEHistoryEntry[]; + isLoading: boolean; + emptyMessage: string; +}) { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (entries.length === 0) { + return ( +
+ +

{emptyMessage}

+
+ ); + } + + return ( + + + + Item + Price + Quantity + Seller + Buyer + Sold At + + + + {entries.map((entry) => ( + + +
+ + {entry.code} +
+
+ + {entry.price.toLocaleString()} + + + {entry.quantity.toLocaleString()} + + + {entry.seller} + + + {entry.buyer} + + + {formatDate(entry.sold_at)} + +
+ ))} +
+
+ ); +} + export default function ExchangePage() { const { data: orders, isLoading: loadingOrders, error: ordersError } = useExchangeOrders(); + const { data: myOrders, isLoading: loadingMyOrders } = useMyOrders(); const { data: history, isLoading: loadingHistory } = useExchangeHistory(); const [marketSearch, setMarketSearch] = useState(""); @@ -172,9 +252,13 @@ export default function ExchangePage() { Market + + + My Orders + - My Orders + Trade History @@ -182,7 +266,7 @@ export default function ExchangePage() { - {/* Market Tab */} + {/* Market Tab - Browse all public orders */}
@@ -199,6 +283,7 @@ export default function ExchangePage() { orders={orders ?? []} isLoading={loadingOrders} search={marketSearch} + showAccount emptyMessage={ marketSearch.trim() ? `No orders found for "${marketSearch}"` @@ -208,13 +293,24 @@ export default function ExchangePage() { - {/* My Orders Tab */} - + {/* My Orders Tab - User's own active orders */} + + + + + {/* Trade History Tab - User's transaction history */} + + + diff --git a/frontend/src/app/map/page.tsx b/frontend/src/app/map/page.tsx index fdee9ba..3677de3 100644 --- a/frontend/src/app/map/page.tsx +++ b/frontend/src/app/map/page.tsx @@ -41,8 +41,7 @@ const CONTENT_COLORS: Record = { const EMPTY_COLOR = "#1f2937"; const CHARACTER_COLOR = "#facc15"; -const GRID_LINE_COLOR = "#374151"; -const BASE_CELL_SIZE = 18; +const BASE_CELL_SIZE = 40; const IMAGE_BASE = "https://artifactsmmo.com/images"; const LEGEND_ITEMS = [ @@ -83,7 +82,8 @@ function loadImage(url: string): Promise { if (cached) return Promise.resolve(cached); return new Promise((resolve, reject) => { const img = new Image(); - img.crossOrigin = "anonymous"; + // Note: do NOT set crossOrigin – the CDN doesn't send CORS headers, + // and we only need to draw the images on canvas (not read pixel data). img.onload = () => { imageCache.set(url, img); resolve(img); @@ -147,7 +147,7 @@ export default function MapPage() { const rafRef = useRef(0); const dragDistRef = useRef(0); - const [zoom, setZoom] = useState(1); + const [zoom, setZoom] = useState(0.5); const [offset, setOffset] = useState({ x: 0, y: 0 }); const [dragging, setDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); @@ -295,14 +295,15 @@ export default function MapPage() { if (px + cellSize < 0 || py + cellSize < 0 || px > width || py > height) continue; - // Try to draw skin image + // Draw skin image – seamless tiles, no gaps const skinImg = tile.skin ? imageCache.get(skinUrl(tile.skin)) : null; if (skinImg) { ctx.drawImage(skinImg, px, py, cellSize, cellSize); } else { - ctx.fillStyle = getTileColor(tile); - ctx.fillRect(px, py, cellSize - 1, cellSize - 1); + // Fallback: dark ground color for tiles without skin images + ctx.fillStyle = EMPTY_COLOR; + ctx.fillRect(px, py, cellSize, cellSize); } // Content icon overlay @@ -315,70 +316,79 @@ export default function MapPage() { const iconSize = cellSize * 0.6; const iconX = px + (cellSize - iconSize) / 2; const iconY = py + (cellSize - iconSize) / 2; + // Drop shadow for icon readability + ctx.shadowColor = "rgba(0,0,0,0.5)"; + ctx.shadowBlur = 3; ctx.drawImage(iconImg, iconX, iconY, iconSize, iconSize); + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; } - } else if (!skinImg) { - // For bank/workshop/etc without skin, draw a colored indicator dot - const dotRadius = Math.max(2, cellSize * 0.15); + } else { + // For bank/workshop/etc – small colored badge in bottom-right + const badgeSize = Math.max(6, cellSize * 0.25); + const badgeX = px + cellSize - badgeSize - 2; + const badgeY = py + cellSize - badgeSize - 2; + ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.beginPath(); - ctx.arc( - px + cellSize / 2, - py + cellSize / 2, - dotRadius, - 0, - Math.PI * 2 - ); + ctx.arc(badgeX + badgeSize / 2, badgeY + badgeSize / 2, badgeSize / 2 + 1, 0, Math.PI * 2); + ctx.fill(); ctx.fillStyle = CONTENT_COLORS[tile.content.type] ?? "#fff"; + ctx.beginPath(); + ctx.arc(badgeX + badgeSize / 2, badgeY + badgeSize / 2, badgeSize / 2, 0, Math.PI * 2); ctx.fill(); } } - // Grid lines (subtler when images are shown) - if (cellSize > 10) { - ctx.strokeStyle = skinImg - ? "rgba(55, 65, 81, 0.3)" - : GRID_LINE_COLOR; - ctx.lineWidth = 0.5; - ctx.strokeRect(px, py, cellSize - 1, cellSize - 1); - } - - // Selected tile highlight + // Selected tile highlight – bright outline if ( selectedTile && tile.x === selectedTile.tile.x && tile.y === selectedTile.tile.y ) { ctx.strokeStyle = "#3b82f6"; - ctx.lineWidth = 2; - ctx.strokeRect(px + 1, py + 1, cellSize - 3, cellSize - 3); + ctx.lineWidth = 2.5; + ctx.strokeRect(px + 1, py + 1, cellSize - 2, cellSize - 2); } - // Coordinate text at high zoom - if (cellSize >= 40) { - ctx.fillStyle = "rgba(255,255,255,0.6)"; - ctx.font = `${Math.max(8, cellSize * 0.2)}px sans-serif`; + // Coordinate text at high zoom – text shadow for readability on tile images + if (cellSize >= 60) { + const fontSize = Math.max(8, cellSize * 0.18); + ctx.font = `${fontSize}px sans-serif`; ctx.textAlign = "left"; ctx.textBaseline = "top"; + ctx.fillStyle = "rgba(0,0,0,0.6)"; + ctx.fillText(`${tile.x},${tile.y}`, px + 3, py + 3); + ctx.fillStyle = "rgba(255,255,255,0.75)"; ctx.fillText(`${tile.x},${tile.y}`, px + 2, py + 2); } // Tile labels at very high zoom - if (cellSize >= 50 && (tile.name || tile.content?.code)) { - ctx.fillStyle = "rgba(255,255,255,0.85)"; - ctx.font = `bold ${Math.max(9, cellSize * 0.18)}px sans-serif`; + if (cellSize >= 80 && (tile.name || tile.content?.code)) { + const nameSize = Math.max(9, cellSize * 0.16); + ctx.font = `bold ${nameSize}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; if (tile.name) { + // Text with shadow + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillText(tile.name, px + cellSize / 2 + 1, py + cellSize - 1); + ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.fillText(tile.name, px + cellSize / 2, py + cellSize - 2); } if (tile.content?.code) { - ctx.font = `${Math.max(8, cellSize * 0.15)}px sans-serif`; - ctx.fillStyle = "rgba(255,255,255,0.65)"; - ctx.textBaseline = "bottom"; + const codeSize = Math.max(8, cellSize * 0.13); + ctx.font = `${codeSize}px sans-serif`; + ctx.fillStyle = "rgba(0,0,0,0.6)"; + ctx.fillText( + tile.content.code, + px + cellSize / 2 + 1, + py + cellSize - 1 - Math.max(10, cellSize * 0.18) + ); + ctx.fillStyle = "rgba(255,255,255,0.75)"; ctx.fillText( tile.content.code, px + cellSize / 2, - py + cellSize - 2 - Math.max(10, cellSize * 0.2) + py + cellSize - 2 - Math.max(10, cellSize * 0.18) ); } } @@ -412,12 +422,15 @@ export default function MapPage() { ctx.lineWidth = 1; ctx.stroke(); - // Label + // Label with shadow for readability on tile images if (cellSize >= 14) { - ctx.fillStyle = "#fff"; - ctx.font = `${Math.max(9, cellSize * 0.5)}px sans-serif`; + const labelFont = `bold ${Math.max(9, cellSize * 0.4)}px sans-serif`; + ctx.font = labelFont; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillText(char.name, px + 1, py - radius - 2); + ctx.fillStyle = "#fff"; ctx.fillText(char.name, px, py - radius - 3); } } @@ -733,7 +746,7 @@ export default function MapPage() { const mouseY = e.clientY - rect.top; const factor = e.deltaY > 0 ? 0.9 : 1.1; - const newZoom = Math.max(0.3, Math.min(5, zoom * factor)); + const newZoom = Math.max(0.1, Math.min(5, zoom * factor)); const scale = newZoom / zoom; // Adjust offset so the point under cursor stays fixed @@ -745,7 +758,7 @@ export default function MapPage() { } function resetView() { - setZoom(1); + setZoom(0.5); setOffset({ x: 0, y: 0 }); } @@ -778,7 +791,7 @@ export default function MapPage() { break; case "-": e.preventDefault(); - setZoom((z) => Math.max(0.3, z * 0.87)); + setZoom((z) => Math.max(0.1, z * 0.87)); break; case "0": e.preventDefault(); @@ -965,7 +978,7 @@ export default function MapPage() { diff --git a/frontend/src/hooks/use-events.ts b/frontend/src/hooks/use-events.ts index 7956007..9345785 100644 --- a/frontend/src/hooks/use-events.ts +++ b/frontend/src/hooks/use-events.ts @@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import { getEvents, getEventHistory } from "@/lib/api-client"; -import type { GameEvent } from "@/lib/types"; +import type { ActiveGameEvent, HistoricalEvent } from "@/lib/types"; export function useEvents() { - return useQuery({ + return useQuery({ queryKey: ["events"], queryFn: getEvents, refetchInterval: 10000, @@ -13,7 +13,7 @@ export function useEvents() { } export function useEventHistory() { - return useQuery({ + return useQuery({ queryKey: ["events", "history"], queryFn: getEventHistory, refetchInterval: 30000, diff --git a/frontend/src/hooks/use-exchange.ts b/frontend/src/hooks/use-exchange.ts index 94273c5..cae5e3d 100644 --- a/frontend/src/hooks/use-exchange.ts +++ b/frontend/src/hooks/use-exchange.ts @@ -3,10 +3,12 @@ import { useQuery } from "@tanstack/react-query"; import { getExchangeOrders, + getMyOrders, getExchangeHistory, + getSellHistory, getPriceHistory, } from "@/lib/api-client"; -import type { GEOrder, PricePoint } from "@/lib/types"; +import type { GEOrder, GEHistoryEntry, PricePoint } from "@/lib/types"; export function useExchangeOrders() { return useQuery({ @@ -16,14 +18,31 @@ export function useExchangeOrders() { }); } -export function useExchangeHistory() { +export function useMyOrders() { return useQuery({ + queryKey: ["exchange", "my-orders"], + queryFn: getMyOrders, + refetchInterval: 10000, + }); +} + +export function useExchangeHistory() { + return useQuery({ queryKey: ["exchange", "history"], queryFn: getExchangeHistory, refetchInterval: 30000, }); } +export function useSellHistory(itemCode: string) { + return useQuery({ + queryKey: ["exchange", "sell-history", itemCode], + queryFn: () => getSellHistory(itemCode), + enabled: !!itemCode, + refetchInterval: 30000, + }); +} + export function usePriceHistory(itemCode: string) { return useQuery({ queryKey: ["exchange", "prices", itemCode], diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index 9de88cc..2f44d13 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -11,8 +11,10 @@ import type { AutomationLog, AutomationStatus, GEOrder, + GEHistoryEntry, PricePoint, - GameEvent, + ActiveGameEvent, + HistoricalEvent, ActionLog, AnalyticsData, } from "./types"; @@ -224,8 +226,20 @@ export async function getExchangeOrders(): Promise { return res.orders; } -export async function getExchangeHistory(): Promise { - const res = await fetchApi<{ history: GEOrder[] }>("/api/exchange/history"); +export async function getMyOrders(): Promise { + const res = await fetchApi<{ orders: GEOrder[] }>("/api/exchange/my-orders"); + return res.orders; +} + +export async function getExchangeHistory(): Promise { + const res = await fetchApi<{ history: GEHistoryEntry[] }>("/api/exchange/history"); + return res.history; +} + +export async function getSellHistory(itemCode: string): Promise { + const res = await fetchApi<{ history: GEHistoryEntry[] }>( + `/api/exchange/sell-history/${encodeURIComponent(itemCode)}` + ); return res.history; } @@ -238,13 +252,13 @@ export async function getPriceHistory(itemCode: string): Promise { // ---------- Events API ---------- -export async function getEvents(): Promise { - const res = await fetchApi<{ events: GameEvent[] }>("/api/events"); +export async function getEvents(): Promise { + const res = await fetchApi<{ events: ActiveGameEvent[] }>("/api/events"); return res.events; } -export async function getEventHistory(): Promise { - const res = await fetchApi<{ events: GameEvent[] }>("/api/events/history"); +export async function getEventHistory(): Promise { + const res = await fetchApi<{ events: HistoricalEvent[] }>("/api/events/history"); return res.events; } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f6fb6d9..5472848 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -257,11 +257,22 @@ export interface GEOrder { id: string; code: string; type: "buy" | "sell"; + account?: string | null; price: number; quantity: number; created_at: string; } +export interface GEHistoryEntry { + order_id: string; + seller: string; + buyer: string; + code: string; + quantity: number; + price: number; + sold_at: string; +} + export interface PricePoint { item_code: string; buy_price: number; @@ -272,6 +283,26 @@ export interface PricePoint { // ---------- Event Types ---------- +export interface ActiveGameEvent { + name: string; + code: string; + content: { type: string; code: string }; + maps: { map_id: number; x: number; y: number; layer: string; skin: string }[]; + duration: number; + rate: number; +} + +export interface HistoricalEvent { + id: number; + event_type: string; + event_data: Record; + character_name?: string; + map_x?: number; + map_y?: number; + created_at: string; +} + +/** @deprecated Use ActiveGameEvent or HistoricalEvent */ export interface GameEvent { id?: string; type: string;