Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11b391d892 | ||
|
|
9eb1cc6cfa | ||
|
|
51a31395a4 | ||
|
|
1e1eb61b75 | ||
|
|
101e3ebd93 |
@@ -27,18 +27,6 @@ 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
|
||||
|
||||
@@ -101,13 +89,6 @@ 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")
|
||||
|
||||
@@ -362,6 +362,29 @@ def create_comment_record(
|
||||
comment_url,
|
||||
)
|
||||
|
||||
# Notify previous commenters on this post (participation)
|
||||
posts = get_table("posts")
|
||||
post = posts.find_one(uid=target_uid)
|
||||
if not post:
|
||||
post = posts.find_one(slug=target_uid)
|
||||
if post:
|
||||
post_owner_uid = post["user_uid"]
|
||||
previous_commenters = set()
|
||||
for c in get_table("comments").find(
|
||||
target_type="post", target_uid=target_uid, deleted_at=None
|
||||
):
|
||||
cu = c["user_uid"]
|
||||
if cu != user["uid"] and cu != post_owner_uid:
|
||||
previous_commenters.add(cu)
|
||||
for cu in previous_commenters:
|
||||
create_notification(
|
||||
cu,
|
||||
"participation",
|
||||
f"{user['username']} also commented on this post",
|
||||
user["uid"],
|
||||
comment_url,
|
||||
)
|
||||
|
||||
create_mention_notifications(content, user["uid"], comment_url)
|
||||
schedule_correction(user, "comments", comment_uid, request)
|
||||
schedule_modification(user, "comments", comment_uid, request)
|
||||
|
||||
@@ -12,6 +12,7 @@ NOTIFICATION_TYPES = [
|
||||
{"key": "vote", "label": "Upvotes", "description": "Someone ++'d your content"},
|
||||
{"key": "follow", "label": "Followers", "description": "Someone starts following you"},
|
||||
{"key": "message", "label": "Direct messages", "description": "Someone sends you a message"},
|
||||
{"key": "participation", "label": "Post participation", "description": "Someone else comments on a post you also commented on"},
|
||||
{"key": "badge", "label": "Badges", "description": "You earn a badge"},
|
||||
{"key": "level", "label": "Level-ups", "description": "You reach a new level"},
|
||||
{"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"},
|
||||
@@ -199,3 +200,4 @@ def mark_notifications_read_by_target(user_uid: str, target_url: str) -> int:
|
||||
|
||||
clear_unread_cache(user_uid)
|
||||
return len(ids)
|
||||
|
||||
|
||||
@@ -1126,7 +1126,6 @@ def init_db():
|
||||
("slot_index", 0),
|
||||
("crop_key", ""),
|
||||
("coins", 0),
|
||||
("insurance_payout", 0),
|
||||
("stolen_at", ""),
|
||||
("created_at", ""),
|
||||
):
|
||||
@@ -1137,42 +1136,6 @@ 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", ""),
|
||||
|
||||
@@ -428,7 +428,7 @@ four ways to sign requests.
|
||||
method="POST",
|
||||
path="/profile/{username}/notifications",
|
||||
title="Toggle a notification preference",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, participation, badge, level, issue, reminder, harvest_stolen.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
@@ -447,7 +447,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
"One of: comment, reply, mention, vote, follow, message, participation, badge, level, issue, reminder, harvest_stolen.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
@@ -795,5 +795,5 @@ four ways to sign requests.
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -635,11 +635,6 @@ 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)
|
||||
|
||||
@@ -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 GameDefenseItemForm, GameSlotForm
|
||||
from devplacepy.models import GameSlotForm
|
||||
from devplacepy.responses import respond, wants_json
|
||||
from devplacepy.schemas import GameFarmViewOut
|
||||
from devplacepy.services.game import GameError, store
|
||||
@@ -110,26 +110,3 @@ 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)
|
||||
|
||||
@@ -36,7 +36,6 @@ from devplacepy.database.awards import (
|
||||
from devplacepy.content import can_view_project, enrich_items
|
||||
from devplacepy.utils import (
|
||||
get_current_user,
|
||||
get_badge,
|
||||
require_user,
|
||||
require_user_api,
|
||||
time_ago,
|
||||
@@ -202,8 +201,6 @@ async def profile_page(
|
||||
item["poll"] = polls_map.get(uid)
|
||||
|
||||
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
||||
for b in badges:
|
||||
b["icon"] = get_badge(b["badge_name"]).get("icon")
|
||||
achievements = build_achievements({b["badge_name"] for b in badges})
|
||||
badge_total = sum(group["total"] for group in achievements)
|
||||
badge_earned = sum(group["earned"] for group in achievements)
|
||||
|
||||
@@ -67,7 +67,6 @@ class PollOut(_Out):
|
||||
|
||||
class BadgeOut(_Out):
|
||||
name: Optional[str] = Field(None, alias="badge_name")
|
||||
icon: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
@@ -178,8 +178,6 @@ 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):
|
||||
@@ -194,18 +192,6 @@ 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 = ""
|
||||
|
||||
@@ -273,25 +273,4 @@ 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(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -723,32 +723,6 @@ 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)
|
||||
|
||||
@@ -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, purchase_defense_item, farm_defense_items, compute_defense_items_effect
|
||||
from .defense import charge_upkeep, downgrade_defense, upgrade_defense
|
||||
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_failed_steals, prune_steals, prune_ticks, recent_harvests
|
||||
from .market import market_factor_for, prune_steals, prune_ticks, recent_harvests
|
||||
from .mastery import upgrade_mastery
|
||||
from .quests import advance_quests, ensure_quests
|
||||
from .treasury import (
|
||||
|
||||
@@ -248,59 +248,6 @@ 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.")
|
||||
@@ -345,57 +292,9 @@ 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",
|
||||
@@ -437,12 +336,6 @@ 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(),
|
||||
@@ -451,7 +344,6 @@ 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),
|
||||
}
|
||||
@@ -464,13 +356,6 @@ 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"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -61,14 +61,6 @@ 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
|
||||
|
||||
|
||||
@@ -4,10 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
from .. import economy
|
||||
from .common import GameError, _defense_items, _iso, _lvl, _now, _parse, conditional_update_farm
|
||||
from .common import GameError, _iso, _lvl, _parse, conditional_update_farm
|
||||
from .farm import ensure_farm, get_farm
|
||||
|
||||
|
||||
@@ -140,74 +138,3 @@ 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,
|
||||
}
|
||||
|
||||
@@ -132,19 +132,3 @@ 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
|
||||
|
||||
@@ -8,7 +8,6 @@ from datetime import datetime, timedelta
|
||||
from .. import economy
|
||||
from .common import (
|
||||
PERK_COLUMN,
|
||||
_defense_items,
|
||||
_iso_week,
|
||||
_lvl,
|
||||
_now,
|
||||
@@ -176,33 +175,6 @@ 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:
|
||||
@@ -396,8 +368,6 @@ 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 {},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
# Notification settings
|
||||
|
||||
DevPlace notifies you when something involves you: a comment on your post, a reply to your comment, a
|
||||
mention, an upvote on your work, a new follower, a direct message, a badge, a level-up, or an update
|
||||
on an issue you filed, or someone gives you an award on your profile. You decide which reach you, and how.
|
||||
mention, an upvote on your work, a new follower, a direct message, another user comments on a post you
|
||||
also commented on, a badge, a level-up, or an update on an issue you filed, or someone gives you an
|
||||
award on your profile. You decide which reach you, and how.
|
||||
|
||||
Each notification type is delivered on three independent channels:
|
||||
|
||||
@@ -40,6 +41,7 @@ immediately - there is no separate save button.
|
||||
| Upvotes | someone `++`'d your post, comment, project, or gist |
|
||||
| Followers | someone starts following you |
|
||||
| Direct messages | someone sends you a message |
|
||||
| Post participation | someone else comments on a post you also commented on |
|
||||
| Badges | you earn a badge |
|
||||
| Level-ups | you reach a new level |
|
||||
| Issue tracker | there is an update on an issue report you filed |
|
||||
@@ -82,3 +84,5 @@ you to confirm a reset first, since that clears all of your choices).
|
||||
<a href="/admin/notifications" class="sidebar-link">Notification defaults (admin)</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
import pytest
|
||||
import requests
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
||||
|
||||
_COUNTER = [0]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _participation_settings(app_server):
|
||||
for key, value in {
|
||||
"rate_limit_per_minute": "1000000",
|
||||
"rate_limit_window_seconds": "60",
|
||||
"registration_open": "1",
|
||||
"maintenance_mode": "0",
|
||||
"max_upload_size_mb": "10",
|
||||
"allowed_file_types": "",
|
||||
"max_attachments_per_resource": "10",
|
||||
"session_max_age_days": "7",
|
||||
"session_remember_days": "30",
|
||||
"news_service_interval": "3600",
|
||||
"news_grade_threshold": "7",
|
||||
}.items():
|
||||
set_setting(key, value)
|
||||
yield
|
||||
|
||||
|
||||
def _db_user(username):
|
||||
refresh_snapshot()
|
||||
return get_table("users").find_one(username=username)
|
||||
|
||||
|
||||
def _unique(prefix="pn"):
|
||||
_COUNTER[0] += 1
|
||||
return f"{prefix}{int(time.time() * 1000)}{_COUNTER[0]}"
|
||||
|
||||
|
||||
def _signup():
|
||||
name = _unique("pnuser")
|
||||
s = requests.Session()
|
||||
s.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s, name
|
||||
|
||||
|
||||
def _create_post(session):
|
||||
r = session.post(
|
||||
f"{BASE_URL}/posts/create",
|
||||
data={
|
||||
"title": _unique("pnpost"),
|
||||
"content": "Post for participation notification test.",
|
||||
"topic": "devlog",
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
slug = r.headers["location"].split("/posts/")[-1]
|
||||
refresh_snapshot()
|
||||
return get_table("posts").find_one(slug=slug)["uid"]
|
||||
|
||||
|
||||
def _comment_on_post(session, post_uid, content):
|
||||
r = session.post(
|
||||
f"{BASE_URL}/comments/create",
|
||||
data={
|
||||
"content": content,
|
||||
"target_type": "post",
|
||||
"post_uid": post_uid,
|
||||
"target_uid": post_uid,
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code in (302, 303), (
|
||||
f"Comment creation failed: {r.status_code} {r.text[:300]}"
|
||||
)
|
||||
|
||||
|
||||
def _reply_to_comment(session, post_uid, parent_uid, content):
|
||||
r = session.post(
|
||||
f"{BASE_URL}/comments/create",
|
||||
data={
|
||||
"content": content,
|
||||
"target_type": "post",
|
||||
"post_uid": post_uid,
|
||||
"target_uid": post_uid,
|
||||
"parent_uid": parent_uid,
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code in (302, 303), (
|
||||
f"Reply creation failed: {r.status_code} {r.text[:300]}"
|
||||
)
|
||||
|
||||
|
||||
def _find_comment(post_uid, content):
|
||||
refresh_snapshot()
|
||||
return get_table("comments").find_one(target_uid=post_uid, content=content)
|
||||
|
||||
|
||||
def _notifications_for(user_uid):
|
||||
refresh_snapshot()
|
||||
return list(
|
||||
get_table("notifications").find(
|
||||
user_uid=user_uid, order_by=["-created_at"]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _participation_notifications_for(user_uid):
|
||||
refresh_snapshot()
|
||||
return list(
|
||||
get_table("notifications").find(
|
||||
user_uid=user_uid, type="participation", order_by=["-created_at"]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_participation_notification_sent(app_server):
|
||||
"""User A receives a participation notification when User B comments
|
||||
on a post that User A previously commented on."""
|
||||
owner_session, owner_name = _signup()
|
||||
post_uid = _create_post(owner_session)
|
||||
|
||||
a_session, a_name = _signup()
|
||||
b_session, b_name = _signup()
|
||||
|
||||
_comment_on_post(a_session, post_uid, "User A first comment")
|
||||
_comment_on_post(b_session, post_uid, "User B comment")
|
||||
|
||||
a_user = _db_user(a_name)
|
||||
assert a_user is not None
|
||||
|
||||
participation_notifs = _participation_notifications_for(a_user["uid"])
|
||||
assert len(participation_notifs) >= 1, (
|
||||
f"User {a_name} should have at least one participation notification, "
|
||||
f"got {len(participation_notifs)}"
|
||||
)
|
||||
latest = participation_notifs[0]
|
||||
assert latest["type"] == "participation"
|
||||
assert b_name in latest["message"], (
|
||||
f"Expected notification message to contain {b_name!r}, "
|
||||
f"got {latest['message']!r}"
|
||||
)
|
||||
assert "also commented" in latest["message"], (
|
||||
f"Expected 'also commented' in message, got {latest['message']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_post_owner_no_participation_duplicate(app_server):
|
||||
"""Post owner does NOT receive a participation notification.
|
||||
They already receive the 'comment' notification."""
|
||||
owner_session, owner_name = _signup()
|
||||
post_uid = _create_post(owner_session)
|
||||
|
||||
a_session, a_name = _signup()
|
||||
b_session, b_name = _signup()
|
||||
|
||||
_comment_on_post(a_session, post_uid, "User A comment for owner test")
|
||||
_comment_on_post(b_session, post_uid, "User B comment for owner test")
|
||||
|
||||
owner = _db_user(owner_name)
|
||||
assert owner is not None
|
||||
|
||||
participation_notifs = _participation_notifications_for(owner["uid"])
|
||||
assert len(participation_notifs) == 0, (
|
||||
f"Post owner {owner_name} should have zero participation notifications, "
|
||||
f"got {len(participation_notifs)}: "
|
||||
f"{[n['message'] for n in participation_notifs]}"
|
||||
)
|
||||
|
||||
all_notifs = _notifications_for(owner["uid"])
|
||||
comment_notifs = [n for n in all_notifs if n["type"] == "comment"]
|
||||
assert len(comment_notifs) >= 1, (
|
||||
f"Post owner should have at least one 'comment' notification, "
|
||||
f"got {len(comment_notifs)}"
|
||||
)
|
||||
|
||||
|
||||
def test_commenter_no_self_notification(app_server):
|
||||
"""Commenter does NOT receive a participation notification
|
||||
for their own comment."""
|
||||
owner_session, _ = _signup()
|
||||
post_uid = _create_post(owner_session)
|
||||
|
||||
a_session, a_name = _signup()
|
||||
_comment_on_post(a_session, post_uid, "User A self-test comment")
|
||||
|
||||
b_session, b_name = _signup()
|
||||
_comment_on_post(b_session, post_uid, "User B self-test comment")
|
||||
|
||||
b_user = _db_user(b_name)
|
||||
assert b_user is not None
|
||||
|
||||
participation_notifs = _participation_notifications_for(b_user["uid"])
|
||||
self_notifs = [
|
||||
n for n in participation_notifs if b_name in n["message"]
|
||||
]
|
||||
assert len(self_notifs) == 0, (
|
||||
f"User {b_name} should not have a participation notification "
|
||||
f"about themselves, got {len(self_notifs)}"
|
||||
)
|
||||
|
||||
|
||||
def test_reply_triggers_participation(app_server):
|
||||
"""A reply to a comment triggers participation notifications
|
||||
for other previous commenters (excluding the reply author and post owner).
|
||||
The implementation sends participation for every comment on a post,
|
||||
both top-level and replies."""
|
||||
owner_session, owner_name = _signup()
|
||||
post_uid = _create_post(owner_session)
|
||||
|
||||
a_session, a_name = _signup()
|
||||
_comment_on_post(a_session, post_uid, "User A top-level comment")
|
||||
a_comment = _find_comment(post_uid, "User A top-level comment")
|
||||
|
||||
c_session, c_name = _signup()
|
||||
_comment_on_post(c_session, post_uid, "User C third participant comment")
|
||||
|
||||
b_session, b_name = _signup()
|
||||
_reply_to_comment(b_session, post_uid, a_comment["uid"], "User B reply")
|
||||
|
||||
a_user = _db_user(a_name)
|
||||
c_user = _db_user(c_name)
|
||||
|
||||
a_participation = _participation_notifications_for(a_user["uid"])
|
||||
c_participation = _participation_notifications_for(c_user["uid"])
|
||||
|
||||
a_has_participation = any(
|
||||
b_name in n["message"] for n in a_participation
|
||||
)
|
||||
c_has_participation = any(
|
||||
b_name in n["message"] for n in c_participation
|
||||
)
|
||||
|
||||
assert a_has_participation, (
|
||||
f"User {a_name} (parent commenter) should receive a participation "
|
||||
f"notification when a reply is posted on the same post. "
|
||||
f"Notifications: {[n['message'] for n in a_participation]}"
|
||||
)
|
||||
assert c_has_participation, (
|
||||
f"User {c_name} (previous commenter) should receive a participation "
|
||||
f"notification when a reply is posted on the same post. "
|
||||
f"Notifications: {[n['message'] for n in c_participation]}"
|
||||
)
|
||||
|
||||
b_user = _db_user(b_name)
|
||||
b_participation = _participation_notifications_for(b_user["uid"])
|
||||
b_self = [n for n in b_participation if b_name in n["message"]]
|
||||
assert len(b_self) == 0, (
|
||||
f"Reply author {b_name} should not receive a participation "
|
||||
f"notification about themselves."
|
||||
)
|
||||
|
||||
@@ -427,65 +427,3 @@ 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
|
||||
|
||||
@@ -46,3 +46,44 @@ def test_member_cannot_set_default(bob):
|
||||
)
|
||||
assert response.status_code in (302, 303, 403)
|
||||
assert get_notification_default("level", "push") is True
|
||||
|
||||
|
||||
def test_admin_page_renders_participation_defaults(alice):
|
||||
_, admin_user = alice
|
||||
admin = _user_notification_prefs(admin_user["username"])
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/admin/notifications",
|
||||
headers={"X-API-KEY": admin["api_key"]},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
html = response.text
|
||||
|
||||
assert "Post participation" in html, (
|
||||
"Admin notifications page should contain 'Post participation' label"
|
||||
)
|
||||
assert "Someone else comments on a post you also commented on" in html, (
|
||||
"Admin notifications page should contain participation description"
|
||||
)
|
||||
|
||||
assert 'data-type="participation" data-channel="in_app"' in html, (
|
||||
"Participation in_app row should exist"
|
||||
)
|
||||
assert 'data-type="participation" data-channel="in_app" checked' in html, (
|
||||
"Participation in_app default should be checked (on)"
|
||||
)
|
||||
|
||||
assert 'data-type="participation" data-channel="push"' in html, (
|
||||
"Participation push row should exist"
|
||||
)
|
||||
assert 'data-type="participation" data-channel="push" checked' in html, (
|
||||
"Participation push default should be checked (on)"
|
||||
)
|
||||
|
||||
assert 'data-type="participation" data-channel="telegram"' in html, (
|
||||
"Participation telegram row should exist"
|
||||
)
|
||||
assert (
|
||||
'data-type="participation" data-channel="telegram" checked' not in html
|
||||
), "Participation telegram default should NOT be checked (off)"
|
||||
|
||||
|
||||
@@ -1177,145 +1177,3 @@ 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")
|
||||
|
||||
Reference in New Issue
Block a user