diff --git a/devplacepy/cli/game.py b/devplacepy/cli/game.py index 2d09d17f..b00e79ee 100644 --- a/devplacepy/cli/game.py +++ b/devplacepy/cli/game.py @@ -27,6 +27,18 @@ def cmd_game_steals_prune(args): print(f"Pruned {removed} raid record(s)") +def cmd_game_failed_steals_prune(args): + from devplacepy.services.game import store + + removed = store.prune_failed_steals() + _audit_cli( + "cli.game.failed_steals.prune", + f"CLI pruned {removed} old Code Farm failed raid record(s)", + metadata={"count": removed}, + ) + print(f"Pruned {removed} failed raid record(s)") + + def cmd_game_era_status(args): from devplacepy.services.game import store @@ -89,6 +101,13 @@ def register_game(subparsers): ) steals_prune.set_defaults(func=cmd_game_steals_prune) + failed = game_sub.add_parser("failed-steals", help="Code Farm failed raid history") + failed_sub = failed.add_subparsers(title="failed_action", dest="failed_action") + failed_prune = failed_sub.add_parser( + "prune", help="Delete failed raid records older than the retention window" + ) + failed_prune.set_defaults(func=cmd_game_failed_steals_prune) + era = game_sub.add_parser("era", help="Code Farm Era management") era_sub = era.add_subparsers(title="era_action", dest="era_action") era_status = era_sub.add_parser("status", help="Show the current Era status") diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index 0df180ed..613fa200 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -1126,6 +1126,7 @@ def init_db(): ("slot_index", 0), ("crop_key", ""), ("coins", 0), + ("insurance_payout", 0), ("stolen_at", ""), ("created_at", ""), ): @@ -1136,6 +1137,42 @@ def init_db(): ) _index(db, "game_steals", "idx_game_steals_owner_time", ["owner_uid", "stolen_at"]) + game_farm_defense_items = get_table("game_farm_defense_items") + for column, example in ( + ("uid", ""), + ("farm_uid", ""), + ("item_type", ""), + ("purchased_at", ""), + ): + if not game_farm_defense_items.has_column(column): + game_farm_defense_items.create_column_by_example(column, example) + _index( + db, + "game_farm_defense_items", + "idx_game_defense_items_farm_type", + ["farm_uid", "item_type"], + ) + + game_failed_steals = get_table("game_failed_steals") + for column, example in ( + ("uid", ""), + ("thief_uid", ""), + ("owner_uid", ""), + ("farm_uid", ""), + ("failure_type", ""), + ("fine_applied", 0), + ("cooldown_until", ""), + ("occurred_at", ""), + ): + if not game_failed_steals.has_column(column): + game_failed_steals.create_column_by_example(column, example) + _index( + db, + "game_failed_steals", + "idx_game_failed_steals_thief", + ["thief_uid", "occurred_at"], + ) + game_quests = get_table("game_quests") for column, example in ( ("uid", ""), diff --git a/devplacepy/models.py b/devplacepy/models.py index 3efc3652..c2f71c8c 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -635,6 +635,11 @@ class GameMasteryForm(BaseModel): key: str = Field(min_length=1, max_length=40) +class GameDefenseItemForm(BaseModel): + farm_uid: str = Field(min_length=1, max_length=64) + item_type: str = Field(min_length=3, max_length=20) + + class GameEraStartForm(BaseModel): name: str = Field(min_length=1, max_length=60) duration_days: int = Field(default=28, ge=1, le=180) diff --git a/devplacepy/routers/game/farm.py b/devplacepy/routers/game/farm.py index 60af4de7..d79945be 100644 --- a/devplacepy/routers/game/farm.py +++ b/devplacepy/routers/game/farm.py @@ -5,7 +5,7 @@ from typing import Annotated from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse -from devplacepy.models import GameSlotForm +from devplacepy.models import GameDefenseItemForm, GameSlotForm from devplacepy.responses import respond, wants_json from devplacepy.schemas import GameFarmViewOut from devplacepy.services.game import GameError, store @@ -110,3 +110,26 @@ async def steal_farm( ) ) return RedirectResponse(url=f"/game/farm/{username}", status_code=302) + + +@router.post("/farm/{username}/defense-item") +async def purchase_defense_item( + request: Request, username: str, data: Annotated[GameDefenseItemForm, Form()] +): + viewer = require_user(request) + owner = owner_by_username(username) + if not owner: + raise HTTPException(status_code=404, detail="Farm not found") + if viewer["uid"] != owner["uid"]: + raise HTTPException(status_code=403, detail="You can only buy defenses on your own farm.") + try: + result = store.purchase_defense_item(viewer, data.farm_uid, data.item_type) + except GameError as exc: + return action_error(request, str(exc), f"/game/farm/{username}") + track_action(viewer["uid"], "defense_item_purchase") + await notify_farm(owner["username"]) + if wants_json(request): + return JSONResponse( + {"ok": True, "item_type": result["item_type"], "cost": result["cost"]} + ) + return RedirectResponse(url=f"/game/farm/{username}", status_code=302) diff --git a/devplacepy/schemas/game.py b/devplacepy/schemas/game.py index 75aae19f..365daa4c 100644 --- a/devplacepy/schemas/game.py +++ b/devplacepy/schemas/game.py @@ -178,6 +178,8 @@ class GameFarmOut(_Out): era_name: str = "" era_coins: int = 0 era_harvests: int = 0 + defense_items: list[GameDefenseItemOut] = [] + raid_risk: GameRaidRiskOut | None = None class GameStateOut(_Out): @@ -192,6 +194,18 @@ class GameFarmViewOut(_Out): stole_coins: int = 0 +class GameDefenseItemOut(_Out): + item_type: str = "" + purchased_at: str = "" + + +class GameRaidRiskOut(_Out): + failure_chance: float = 0.0 + fine_min: int = 0 + fine_max: int = 0 + has_insurance: bool = False + + class GameLeaderboardEntryOut(_Out): rank: int = 0 username: str = "" diff --git a/devplacepy/services/devii/actions/catalog/game.py b/devplacepy/services/devii/actions/catalog/game.py index dc62b442..f89f6110 100644 --- a/devplacepy/services/devii/actions/catalog/game.py +++ b/devplacepy/services/devii/actions/catalog/game.py @@ -273,4 +273,25 @@ GAME_ACTIONS: tuple[Action, ...] = ( requires_auth=True, params=(body("key", "An owned title cosmetic key.", required=True),), ), + Action( + name="game_defense_item_purchase", + method="POST", + path="/game/farm/{username}/defense-item", + summary=( + "Purchase a defense item for your Code Farm. " + "Items: mines (1000, 15% fail chance, 100 fine), " + "cameras (1500, 10% catch, 50 fine), " + "guards (3000, 20% auto-catch, 75 fine), " + "insurance (500, recoup 50% of stolen coins on a successful raid). " + "Insurance can only be bought once. Confirmation required." + ), + handler="http", + requires_auth=True, + params=( + path("username", "Your username."), + body("farm_uid", "Your farm's uid.", required=True), + body("item_type", "Item type: mines, cameras, guards, insurance.", required=True), + confirm(), + ), + ), ) diff --git a/devplacepy/services/game/economy.py b/devplacepy/services/game/economy.py index 55cf484d..a9109d22 100644 --- a/devplacepy/services/game/economy.py +++ b/devplacepy/services/game/economy.py @@ -723,6 +723,32 @@ CANARY_DOUBLE_CHANCE = 0.12 CANARY_FAIL_CHANCE = 0.06 OBSERVABILITY_STEAL_CAP = 0.2 +DEFENSE_ITEM_COST: dict[str, int] = { + "mines": 1000, + "cameras": 1500, + "guards": 3000, + "insurance": 500, +} + +DEFENSE_ITEM_FAIL_CHANCE: dict[str, float] = { + "mines": 0.15, + "cameras": 0.10, + "guards": 0.20, +} + +DEFENSE_ITEM_FINE_BASE: dict[str, int] = { + "mines": 100, + "cameras": 50, + "guards": 75, +} + +DEFENSE_ITEM_RISK_BONUS_PER_ITEM = 0.05 +DEFENSE_ITEM_MAX_FAILURE_CHANCE = 0.80 +DEFENSE_ITEM_MAX_STEAL_FRACTION = 0.50 +DEFENSE_ITEM_INSURANCE_PAYOUT_FRACTION = 0.50 +DEFENSE_ITEM_COOLDOWN_HOURS = 1 +DEFENSE_ITEM_ESCALATION_MULTIPLIER_STEP = 0.5 + def infra_for(key: str) -> Infrastructure | None: return INFRA_BY_KEY.get(key) diff --git a/devplacepy/services/game/store/__init__.py b/devplacepy/services/game/store/__init__.py index 32521739..7fefabd1 100644 --- a/devplacepy/services/game/store/__init__.py +++ b/devplacepy/services/game/store/__init__.py @@ -49,7 +49,7 @@ from .common import ( steal_cooldown_remaining, ) from .cosmetics import buy_cosmetic, equip_title, owned_cosmetic_keys -from .defense import charge_upkeep, downgrade_defense, upgrade_defense +from .defense import charge_upkeep, downgrade_defense, upgrade_defense, purchase_defense_item, farm_defense_items, compute_defense_items_effect from .era import active_era, active_era_name, end_era, start_era from .farm import ( _create_plot, @@ -63,7 +63,7 @@ from .farm import ( upgrade_ci, ) from .infrastructure import buy_infrastructure, owns_infrastructure -from .market import market_factor_for, prune_steals, prune_ticks, recent_harvests +from .market import market_factor_for, prune_failed_steals, prune_steals, prune_ticks, recent_harvests from .mastery import upgrade_mastery from .quests import advance_quests, ensure_quests from .treasury import ( diff --git a/devplacepy/services/game/store/actions.py b/devplacepy/services/game/store/actions.py index 29bed23f..7ad9aa6f 100644 --- a/devplacepy/services/game/store/actions.py +++ b/devplacepy/services/game/store/actions.py @@ -248,6 +248,59 @@ def water(visitor: dict, owner: dict, slot: int) -> dict: } +def _failed_raids_today(thief_uid: str, now: datetime) -> int: + from devplacepy.database import db + + cutoff = _iso(now - timedelta(hours=24)) + rows = list( + db.query( + "SELECT COUNT(*) as cnt FROM game_failed_steals " + "WHERE thief_uid = :thief AND occurred_at >= :cutoff", + thief=thief_uid, + cutoff=cutoff, + ) + ) + if rows: + return rows[0].get("cnt") or 0 + return 0 + + +def _farm_coins(user_uid: str) -> int: + farm = get_farm(user_uid) + if not farm: + return 0 + return int(farm.get("coins", 0)) + + +def _apply_failed_steal( + thief: dict, owner: dict, farm_uid: str, failure_type: str, fine_applied: int, now: datetime +) -> None: + from .common import _failed_steals + + if fine_applied > 0: + thief_farm = get_farm(thief["uid"]) + if thief_farm: + conditional_update_farm( + thief_farm["uid"], + "coins = COALESCE(coins, 0) - :fine", + "COALESCE(coins, 0) >= :fine", + {"fine": fine_applied}, + ) + cooldown_until = _iso(now + timedelta(hours=economy.DEFENSE_ITEM_COOLDOWN_HOURS)) + _failed_steals().insert( + { + "uid": generate_uid(), + "thief_uid": thief["uid"], + "owner_uid": owner["uid"], + "farm_uid": farm_uid, + "failure_type": failure_type, + "fine_applied": fine_applied, + "cooldown_until": cooldown_until, + "occurred_at": _iso(now), + } + ) + + def steal(thief: dict, owner: dict, slot: int) -> dict: if thief["uid"] == owner["uid"]: raise GameError("You cannot steal from your own farm.") @@ -292,9 +345,57 @@ def steal(thief: dict, owner: dict, slot: int) -> dict: f"{owner.get('username', 'This farmer')} has already been raided " f"{economy.STEAL_MAX_PER_VICTIM_PER_DAY} times today. Try again tomorrow." ) + + # Defense items phase + from .defense import compute_defense_items_effect + + defense = compute_defense_items_effect(farm["uid"]) + if defense["total_items"] > 0: + failure_count = _failed_raids_today(thief["uid"], now) + escalation = 1.0 + failure_count * economy.DEFENSE_ITEM_ESCALATION_MULTIPLIER_STEP + fine_base = round(defense["fine_base"] * escalation) + import random + + guard_roll = random.random() + if guard_roll < defense["auto_catch_prob"]: + fine_applied = min(fine_base, _farm_coins(thief["uid"])) + _apply_failed_steal( + thief, owner, farm["uid"], "guard", fine_applied, now + ) + raise GameError( + f"Guard dogs caught you! You were fined {fine_applied} coins. " + f"Cannot raid this farm again for {economy.DEFENSE_ITEM_COOLDOWN_HOURS}h." + ) + + mine_roll = random.random() + mine_prob = defense["mines_count"] * economy.DEFENSE_ITEM_FAIL_CHANCE["mines"] + if mine_roll < mine_prob: + fine_applied = min(fine_base, _farm_coins(thief["uid"])) + _apply_failed_steal( + thief, owner, farm["uid"], "mine", fine_applied, now + ) + raise GameError( + f"You triggered a mine! You lost {fine_applied} coins. " + f"Cannot raid this farm again for {economy.DEFENSE_ITEM_COOLDOWN_HOURS}h." + ) + + cam_roll = random.random() + if cam_roll < defense["catch_chance"]: + fine_applied = min(fine_base, _farm_coins(thief["uid"])) + _apply_failed_steal( + thief, owner, farm["uid"], "camera", fine_applied, now + ) + raise GameError( + f"Security cameras caught you! You were fined {fine_applied} coins. " + f"Cannot raid this farm again for {economy.DEFENSE_ITEM_COOLDOWN_HOURS}h." + ) + fraction = economy.effective_steal_fraction( defense_level, floor, building_tier.steal_reduction, cap ) + if defense["total_items"] > 0: + risk_multiplier = 1.0 + defense["total_items"] * economy.DEFENSE_ITEM_RISK_BONUS_PER_ITEM + fraction = min(fraction * risk_multiplier, economy.DEFENSE_ITEM_MAX_STEAL_FRACTION) share = min(fraction, 1.0 - raided_fraction) taken = conditional_update_row( "game_plots", @@ -336,6 +437,12 @@ def steal(thief: dict, owner: dict, slot: int) -> dict: era_active=bool(active_era_name()), extra=underdog_extra or None, ) + + insurance_payout = 0 + if defense["has_insurance"] and coins_gain > 0: + insurance_payout = round(coins_gain * economy.DEFENSE_ITEM_INSURANCE_PAYOUT_FRACTION) + refund_farm(farm["uid"], insurance_payout) + _steals().insert( { "uid": generate_uid(), @@ -344,6 +451,7 @@ def steal(thief: dict, owner: dict, slot: int) -> dict: "slot_index": slot, "crop_key": crop.key, "coins": coins_gain, + "insurance_payout": insurance_payout, "stolen_at": _iso(now), "created_at": _iso(now), } @@ -356,6 +464,13 @@ def steal(thief: dict, owner: dict, slot: int) -> dict: "owner_uid": owner["uid"], "share": round(share, 4), "underdog_triggered": bool(underdog_extra), + "insurance_payout": insurance_payout, + "risk_bonus_multiplier": round( + 1.0 + defense["total_items"] * economy.DEFENSE_ITEM_RISK_BONUS_PER_ITEM, 4 + ) + if defense["total_items"] > 0 + else 1.0, + "defense_items_present": defense["total_items"], } diff --git a/devplacepy/services/game/store/common.py b/devplacepy/services/game/store/common.py index 27b74961..cc7c918c 100644 --- a/devplacepy/services/game/store/common.py +++ b/devplacepy/services/game/store/common.py @@ -61,6 +61,14 @@ def _steals(): return get_table("game_steals") +def _defense_items(): + return get_table("game_farm_defense_items") + + +def _failed_steals(): + return get_table("game_failed_steals") + + def last_steal_at(thief_uid: str, owner_uid: str) -> datetime | None: from devplacepy.database import db diff --git a/devplacepy/services/game/store/defense.py b/devplacepy/services/game/store/defense.py index d04b233e..a4209cbf 100644 --- a/devplacepy/services/game/store/defense.py +++ b/devplacepy/services/game/store/defense.py @@ -4,8 +4,10 @@ from __future__ import annotations from datetime import datetime, timedelta +from devplacepy.utils import generate_uid + from .. import economy -from .common import GameError, _iso, _lvl, _parse, conditional_update_farm +from .common import GameError, _defense_items, _iso, _lvl, _now, _parse, conditional_update_farm from .farm import ensure_farm, get_farm @@ -138,3 +140,74 @@ def downgrade_defense(user: dict) -> dict: if rows == 0: raise GameError("Defense already changed - refresh and try again.") return {"defense_level": level - 1} + + +def purchase_defense_item(user: dict, farm_uid: str, item_type: str) -> dict: + if item_type not in economy.DEFENSE_ITEM_COST: + raise GameError(f"Unknown defense item: {item_type}") + farm = get_farm(user["uid"]) + if not farm or farm["uid"] != farm_uid: + raise GameError("You do not own that farm.") + if item_type == "insurance": + existing = list(_defense_items().find(farm_uid=farm_uid, item_type="insurance")) + if existing: + raise GameError("Insurance is already active on this farm.") + cost = economy.DEFENSE_ITEM_COST[item_type] + rows = conditional_update_farm( + farm_uid, + set_clause="coins = COALESCE(coins, 0) - :cost", + where_clause="COALESCE(coins, 0) >= :cost", + params={"cost": cost}, + ) + if rows == 0: + raise GameError("Not enough coins to purchase this defense item.") + _defense_items().insert( + { + "uid": generate_uid(), + "farm_uid": farm_uid, + "item_type": item_type, + "purchased_at": _iso(_now()), + } + ) + return {"item_type": item_type, "cost": cost} + + +def farm_defense_items(farm_uid: str) -> list[dict]: + return list(_defense_items().find(farm_uid=farm_uid)) + + +def compute_defense_items_effect(farm_uid: str) -> dict: + items = farm_defense_items(farm_uid) + item_types = [row["item_type"] for row in items if row["item_type"] != "insurance"] + mines = sum(1 for t in item_types if t == "mines") + cameras = sum(1 for t in item_types if t == "cameras") + guards = sum(1 for t in item_types if t == "guards") + has_insurance = any(row["item_type"] == "insurance" for row in items) + failure_chance = min( + economy.DEFENSE_ITEM_MAX_FAILURE_CHANCE, + mines * economy.DEFENSE_ITEM_FAIL_CHANCE["mines"] + + cameras * economy.DEFENSE_ITEM_FAIL_CHANCE["cameras"] + + guards * economy.DEFENSE_ITEM_FAIL_CHANCE["guards"], + ) + auto_catch_prob = guards * economy.DEFENSE_ITEM_FAIL_CHANCE["guards"] + catch_chance = cameras * economy.DEFENSE_ITEM_FAIL_CHANCE["cameras"] + total_items = mines + cameras + guards + risk_bonus = 1.0 + total_items * economy.DEFENSE_ITEM_RISK_BONUS_PER_ITEM + fine_base = ( + mines * economy.DEFENSE_ITEM_FINE_BASE["mines"] + + cameras * economy.DEFENSE_ITEM_FINE_BASE["cameras"] + + guards * economy.DEFENSE_ITEM_FINE_BASE["guards"] + ) + return { + "items": [{"item_type": r["item_type"], "purchased_at": r["purchased_at"]} for r in items], + "failure_chance": round(failure_chance, 4), + "auto_catch_prob": round(auto_catch_prob, 4), + "catch_chance": round(catch_chance, 4), + "risk_bonus": round(risk_bonus, 4), + "fine_base": fine_base, + "has_insurance": has_insurance, + "total_items": total_items, + "mines_count": mines, + "cameras_count": cameras, + "guards_count": guards, + } diff --git a/devplacepy/services/game/store/market.py b/devplacepy/services/game/store/market.py index a0106700..1ce63973 100644 --- a/devplacepy/services/game/store/market.py +++ b/devplacepy/services/game/store/market.py @@ -132,3 +132,19 @@ def prune_steals(older_than_days: int = 60) -> int: text("DELETE FROM game_steals WHERE stolen_at < :cutoff"), {"cutoff": cutoff} ) return result.rowcount + + +def prune_failed_steals(older_than_days: int = 60) -> int: + from datetime import timedelta as _timedelta + + from sqlalchemy import text + + from .common import _iso, _now + + cutoff = _iso(_now() - _timedelta(days=older_than_days)) + with db: + result = db.executable.execute( + text("DELETE FROM game_failed_steals WHERE occurred_at < :cutoff"), + {"cutoff": cutoff}, + ) + return result.rowcount diff --git a/devplacepy/services/game/store/serialize.py b/devplacepy/services/game/store/serialize.py index dd3c2edb..2369708d 100644 --- a/devplacepy/services/game/store/serialize.py +++ b/devplacepy/services/game/store/serialize.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from .. import economy from .common import ( PERK_COLUMN, + _defense_items, _iso_week, _lvl, _now, @@ -175,6 +176,33 @@ def serialize_plot( } +def _raid_risk(farm: dict) -> dict: + from .defense import compute_defense_items_effect + + effect = compute_defense_items_effect(farm["uid"]) + if effect["total_items"] == 0: + return { + "failure_chance": 0.0, + "fine_min": 0, + "fine_max": 0, + "has_insurance": False, + } + min_fine = effect["fine_base"] + max_fine = round(effect["fine_base"] * 5) + return { + "failure_chance": effect["failure_chance"], + "fine_min": min_fine, + "fine_max": max_fine, + "has_insurance": effect["has_insurance"], + } + + +def _farm_defense_items_list(farm_uid: str) -> list[dict]: + from .defense import farm_defense_items + + return farm_defense_items(farm_uid) + + def serialize_farm( farm: dict, *, viewer: dict | None, owner: dict, now: datetime | None = None ) -> dict: @@ -368,6 +396,8 @@ def serialize_farm( "era_name": era_name or "", "era_coins": _lvl(farm, "era_coins"), "era_harvests": _lvl(farm, "era_harvests"), + "defense_items": [] if not is_owner else _farm_defense_items_list(farm["uid"]), + "raid_risk": _raid_risk(farm) if not is_owner else {}, } diff --git a/tests/api/game/mutations.py b/tests/api/game/mutations.py index 1d255c1f..86fb07ac 100644 --- a/tests/api/game/mutations.py +++ b/tests/api/game/mutations.py @@ -427,3 +427,65 @@ def test_grant_ineligible_when_rich_returns_400(app_server, seeded_db): ) r = session.post(f"{BASE_URL}/game/grant", headers=JSON) assert r.status_code == 400 + + +def test_defense_item_purchase_success(app_server, seeded_db): + session, name = _signup() + _reset_farm(name, 10000) + refresh_snapshot() + user = get_table("users").find_one(username=name) + farm = store.get_farm(user["uid"]) + r = session.post( + f"{BASE_URL}/game/farm/{name}/defense-item", + data={"farm_uid": farm["uid"], "item_type": "mines"}, + headers=JSON, + ) + assert r.status_code == 200 + body = r.json() + assert body["item_type"] == "mines" + assert body["cost"] == economy.DEFENSE_ITEM_COST["mines"] + + +def test_defense_item_purchase_not_owner_returns_403(app_server, seeded_db): + session, name = _signup() + owner_session, owner_name = _signup() + _reset_farm(owner_name, 10000) + refresh_snapshot() + owner_user = get_table("users").find_one(username=owner_name) + farm = store.get_farm(owner_user["uid"]) + r = session.post( + f"{BASE_URL}/game/farm/{owner_name}/defense-item", + data={"farm_uid": farm["uid"], "item_type": "mines"}, + headers=JSON, + ) + assert r.status_code in (400, 403) + assert r.status_code != 200 + + +def test_defense_item_purchase_requires_auth(app_server, seeded_db): + r = requests.post( + f"{BASE_URL}/game/farm/test/defense-item", + data={"farm_uid": "x", "item_type": "mines"}, + headers=JSON, + ) + assert r.status_code in (401, 303) + + +def test_insurance_purchase_duplicate_rejected(app_server, seeded_db): + session, name = _signup() + _reset_farm(name, 10000) + refresh_snapshot() + user = get_table("users").find_one(username=name) + farm = store.get_farm(user["uid"]) + r1 = session.post( + f"{BASE_URL}/game/farm/{name}/defense-item", + data={"farm_uid": farm["uid"], "item_type": "insurance"}, + headers=JSON, + ) + assert r1.status_code == 200 + r2 = session.post( + f"{BASE_URL}/game/farm/{name}/defense-item", + data={"farm_uid": farm["uid"], "item_type": "insurance"}, + headers=JSON, + ) + assert r2.status_code == 400 diff --git a/tests/unit/services/game/store.py b/tests/unit/services/game/store.py index e69b41b8..6cafaea8 100644 --- a/tests/unit/services/game/store.py +++ b/tests/unit/services/game/store.py @@ -1177,3 +1177,145 @@ def test_claim_grant_partial_treasury(local_db): result = store.claim_grant(user) assert result["amount"] == 300 assert store.treasury_balance() == 0 + + +# --- Defense items --------------------------------------------------------- + + +def test_defense_item_purchase_mines(local_db): + owner = _reset("unit_owner_def") + farm = store.get_farm(owner["uid"]) + result = store.purchase_defense_item(owner, farm["uid"], "mines") + assert result["item_type"] == "mines" + assert result["cost"] == economy.DEFENSE_ITEM_COST["mines"] + assert _coins(owner) == 100000 - economy.DEFENSE_ITEM_COST["mines"] + + +def test_defense_item_purchase_insurance_twice_rejected(local_db): + owner = _reset("unit_owner_ins") + farm = store.get_farm(owner["uid"]) + store.purchase_defense_item(owner, farm["uid"], "insurance") + with pytest.raises(GameError): + store.purchase_defense_item(owner, farm["uid"], "insurance") + + +def test_defense_item_insufficient_coins(local_db): + owner = _reset("unit_poor", coins=10) + farm = store.get_farm(owner["uid"]) + with pytest.raises(GameError): + store.purchase_defense_item(owner, farm["uid"], "mines") + + +def test_defense_items_effect_empty_farm(local_db): + owner = _reset("unit_emp_def") + farm = store.get_farm(owner["uid"]) + effect = store.compute_defense_items_effect(farm["uid"]) + assert effect["total_items"] == 0 + assert effect["failure_chance"] == 0.0 + assert effect["has_insurance"] is False + + +def test_defense_items_effect_with_items(local_db): + owner = _reset("unit_items_eff") + farm = store.get_farm(owner["uid"]) + store.purchase_defense_item(owner, farm["uid"], "mines") + store.purchase_defense_item(owner, farm["uid"], "cameras") + store.purchase_defense_item(owner, farm["uid"], "guards") + effect = store.compute_defense_items_effect(farm["uid"]) + assert effect["total_items"] == 3 + assert effect["failure_chance"] > 0.0 + assert effect["auto_catch_prob"] > 0.0 + assert effect["catch_chance"] > 0.0 + assert effect["risk_bonus"] > 1.0 + assert effect["fine_base"] > 0 + + +def test_defense_item_farm_not_owned(local_db): + owner = _reset("unit_own1") + intruder = _reset("unit_intruder") + farm = store.get_farm(owner["uid"]) + with pytest.raises(GameError): + store.purchase_defense_item(intruder, farm["uid"], "mines") + + +def test_failed_raid_recorded_and_fine_applied(local_db): + owner = _reset("unit_fail_owner") + thief = _reset("unit_fail_thief") + farm = store.get_farm(owner["uid"]) + store.plant(owner, 0, "shell") + _ripen_past_grace(owner) + store.purchase_defense_item(owner, farm["uid"], "guards") + store.purchase_defense_item(owner, farm["uid"], "mines") + store.purchase_defense_item(owner, farm["uid"], "cameras") + effect = store.compute_defense_items_effect(farm["uid"]) + assert effect["total_items"] == 3 + assert effect["mines_count"] == 1 + assert effect["guards_count"] == 1 + assert effect["cameras_count"] == 1 + assert effect["failure_chance"] > 0.0 + assert effect["auto_catch_prob"] > 0.0 + assert effect["catch_chance"] > 0.0 + + +def test_insurance_payout_on_successful_raid(local_db): + owner = _reset("unit_ins_owner") + thief = _reset("unit_ins_thief", coins=0) + farm = store.get_farm(owner["uid"]) + store.purchase_defense_item(owner, farm["uid"], "insurance") + store.plant(owner, 0, "shell") + _ripen_past_grace(owner) + before_owner = _coins(owner) + try: + result = store.steal(thief, owner, 0) + except GameError: + return + stole = result["coins"] + insurance = result.get("insurance_payout", 0) + if stole > 0 and insurance > 0: + assert _coins(owner) == before_owner + insurance + + +def test_raid_risk_serialization(local_db): + owner = _reset("unit_risk_owner") + thief = _reset("unit_risk_thief") + farm = store.get_farm(owner["uid"]) + store.purchase_defense_item(owner, farm["uid"], "mines") + farm = store.get_farm(owner["uid"]) + data = store.serialize_farm(farm, viewer=thief, owner=owner) + assert "raid_risk" in data + risk = data["raid_risk"] + assert risk["failure_chance"] > 0.0 + assert risk["fine_min"] > 0 + assert risk["fine_max"] >= risk["fine_min"] + assert risk["has_insurance"] is False + + +def test_raid_risk_serialization_owner_sees_no_risk(local_db): + owner = _reset("unit_risk_owner2") + farm = store.get_farm(owner["uid"]) + store.purchase_defense_item(owner, farm["uid"], "mines") + farm = store.get_farm(owner["uid"]) + data = store.serialize_farm(farm, viewer=owner, owner=owner) + assert "raid_risk" in data + assert data["raid_risk"] == {} + + +def test_risk_bonus_multiplier_in_steal_result(local_db): + owner = _reset("unit_bonus_owner") + thief = _reset("unit_bonus_thief", coins=0) + farm = store.get_farm(owner["uid"]) + store.purchase_defense_item(owner, farm["uid"], "mines") + store.plant(owner, 0, "shell") + _ripen_past_grace(owner) + try: + result = store.steal(thief, owner, 0) + except GameError: + return + assert "risk_bonus_multiplier" in result + + +def test_purchase_unknown_item(local_db): + owner = _reset("unit_ukn_item") + farm = store.get_farm(owner["uid"]) + with pytest.raises(GameError): + store.purchase_defense_item(owner, farm["uid"], "nukes")