Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f44e3d1db8 | ||
|
|
9d14149f62 |
@@ -27,18 +27,6 @@ def cmd_game_steals_prune(args):
|
|||||||
print(f"Pruned {removed} raid record(s)")
|
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):
|
def cmd_game_era_status(args):
|
||||||
from devplacepy.services.game import store
|
from devplacepy.services.game import store
|
||||||
|
|
||||||
@@ -101,13 +89,6 @@ def register_game(subparsers):
|
|||||||
)
|
)
|
||||||
steals_prune.set_defaults(func=cmd_game_steals_prune)
|
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 = game_sub.add_parser("era", help="Code Farm Era management")
|
||||||
era_sub = era.add_subparsers(title="era_action", dest="era_action")
|
era_sub = era.add_subparsers(title="era_action", dest="era_action")
|
||||||
era_status = era_sub.add_parser("status", help="Show the current Era status")
|
era_status = era_sub.add_parser("status", help="Show the current Era status")
|
||||||
|
|||||||
@@ -60,6 +60,21 @@ REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_by_uid(project_uid: str | None) -> dict | None:
|
||||||
|
if not project_uid:
|
||||||
|
return None
|
||||||
|
project = get_table("projects").find_one(uid=project_uid)
|
||||||
|
if not project:
|
||||||
|
return None
|
||||||
|
slug = project.get("slug") or project["uid"]
|
||||||
|
return {
|
||||||
|
"uid": project["uid"],
|
||||||
|
"name": project.get("title") or project.get("name", ""),
|
||||||
|
"slug": slug,
|
||||||
|
"url": f"/projects/{slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_owner(item: dict | None, user: dict | None) -> bool:
|
def is_owner(item: dict | None, user: dict | None) -> bool:
|
||||||
return bool(item and user and item["user_uid"] == user["uid"])
|
return bool(item and user and item["user_uid"] == user["uid"])
|
||||||
|
|
||||||
@@ -512,6 +527,7 @@ def detail_context(
|
|||||||
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
|
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
|
||||||
"bookmarked": detail.get("bookmarked", False),
|
"bookmarked": detail.get("bookmarked", False),
|
||||||
"poll": detail.get("poll"),
|
"poll": detail.get("poll"),
|
||||||
|
"project_link": detail.get("project_link"),
|
||||||
}
|
}
|
||||||
if extra:
|
if extra:
|
||||||
context.update(extra)
|
context.update(extra)
|
||||||
@@ -691,6 +707,7 @@ def load_detail(
|
|||||||
"reactions": reactions,
|
"reactions": reactions,
|
||||||
"bookmarked": bookmarked,
|
"bookmarked": bookmarked,
|
||||||
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
|
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
|
||||||
|
"project_link": get_project_by_uid(item.get("project_uid")) if target_type == "post" else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -718,6 +735,8 @@ def enrich_items(
|
|||||||
entry[name] = (
|
entry[name] = (
|
||||||
source(item) if callable(source) else source.get(item["uid"], 0)
|
source(item) if callable(source) else source.get(item["uid"], 0)
|
||||||
)
|
)
|
||||||
|
if key == "post" and item.get("project_uid"):
|
||||||
|
entry["project_link"] = get_project_by_uid(item["project_uid"])
|
||||||
enriched.append(entry)
|
enriched.append(entry)
|
||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ def init_db():
|
|||||||
("is_private", 0),
|
("is_private", 0),
|
||||||
("read_only", 0),
|
("read_only", 0),
|
||||||
("updated_at", ""),
|
("updated_at", ""),
|
||||||
|
("title", ""),
|
||||||
|
("description", ""),
|
||||||
|
("status", ""),
|
||||||
):
|
):
|
||||||
if not projects.has_column(column):
|
if not projects.has_column(column):
|
||||||
projects.create_column_by_example(column, example)
|
projects.create_column_by_example(column, example)
|
||||||
@@ -1126,7 +1129,6 @@ def init_db():
|
|||||||
("slot_index", 0),
|
("slot_index", 0),
|
||||||
("crop_key", ""),
|
("crop_key", ""),
|
||||||
("coins", 0),
|
("coins", 0),
|
||||||
("insurance_payout", 0),
|
|
||||||
("stolen_at", ""),
|
("stolen_at", ""),
|
||||||
("created_at", ""),
|
("created_at", ""),
|
||||||
):
|
):
|
||||||
@@ -1137,42 +1139,6 @@ def init_db():
|
|||||||
)
|
)
|
||||||
_index(db, "game_steals", "idx_game_steals_owner_time", ["owner_uid", "stolen_at"])
|
_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")
|
game_quests = get_table("game_quests")
|
||||||
for column, example in (
|
for column, example in (
|
||||||
("uid", ""),
|
("uid", ""),
|
||||||
|
|||||||
@@ -635,11 +635,6 @@ class GameMasteryForm(BaseModel):
|
|||||||
key: str = Field(min_length=1, max_length=40)
|
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):
|
class GameEraStartForm(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=60)
|
name: str = Field(min_length=1, max_length=60)
|
||||||
duration_days: int = Field(default=28, ge=1, le=180)
|
duration_days: int = Field(default=28, ge=1, le=180)
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ async def create_rant(request: Request):
|
|||||||
if len(text) > 125000:
|
if len(text) > 125000:
|
||||||
return dr_error("Your rant is too long.")
|
return dr_error("Your rant is too long.")
|
||||||
tags = _parse_tags(params.get("tags"))
|
tags = _parse_tags(params.get("tags"))
|
||||||
|
project_uid = params.get("project_uid") or None
|
||||||
uid, slug = create_content_item(
|
uid, slug = create_content_item(
|
||||||
"posts",
|
"posts",
|
||||||
"post",
|
"post",
|
||||||
@@ -95,7 +96,7 @@ async def create_rant(request: Request):
|
|||||||
"title": None,
|
"title": None,
|
||||||
"content": text,
|
"content": text,
|
||||||
"topic": "rant",
|
"topic": "rant",
|
||||||
"project_uid": None,
|
"project_uid": project_uid,
|
||||||
"image": None,
|
"image": None,
|
||||||
"tags": encode_tags(tags),
|
"tags": encode_tags(tags),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
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.responses import respond, wants_json
|
||||||
from devplacepy.schemas import GameFarmViewOut
|
from devplacepy.schemas import GameFarmViewOut
|
||||||
from devplacepy.services.game import GameError, store
|
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)
|
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)
|
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ class BadgeOut(_Out):
|
|||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectLinkOut(_Out):
|
||||||
|
uid: str = ""
|
||||||
|
name: Optional[str] = None
|
||||||
|
slug: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class PostOut(_Out):
|
class PostOut(_Out):
|
||||||
uid: str = ""
|
uid: str = ""
|
||||||
slug: Optional[str] = None
|
slug: Optional[str] = None
|
||||||
@@ -81,6 +88,7 @@ class PostOut(_Out):
|
|||||||
topic: Optional[str] = None
|
topic: Optional[str] = None
|
||||||
stars: Optional[int] = None
|
stars: Optional[int] = None
|
||||||
image: Optional[str] = None
|
image: Optional[str] = None
|
||||||
|
project_uid: Optional[str] = None
|
||||||
created_at: Optional[str] = None
|
created_at: Optional[str] = None
|
||||||
updated_at: Optional[str] = None
|
updated_at: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
@@ -178,8 +178,6 @@ class GameFarmOut(_Out):
|
|||||||
era_name: str = ""
|
era_name: str = ""
|
||||||
era_coins: int = 0
|
era_coins: int = 0
|
||||||
era_harvests: int = 0
|
era_harvests: int = 0
|
||||||
defense_items: list[GameDefenseItemOut] = []
|
|
||||||
raid_risk: GameRaidRiskOut | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class GameStateOut(_Out):
|
class GameStateOut(_Out):
|
||||||
@@ -194,18 +192,6 @@ class GameFarmViewOut(_Out):
|
|||||||
stole_coins: int = 0
|
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):
|
class GameLeaderboardEntryOut(_Out):
|
||||||
rank: int = 0
|
rank: int = 0
|
||||||
username: str = ""
|
username: str = ""
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from devplacepy.schemas.content import (
|
|||||||
NotificationOut,
|
NotificationOut,
|
||||||
PollOut,
|
PollOut,
|
||||||
PostOut,
|
PostOut,
|
||||||
|
ProjectLinkOut,
|
||||||
ProjectOut,
|
ProjectOut,
|
||||||
ReactionsOut,
|
ReactionsOut,
|
||||||
UserOut,
|
UserOut,
|
||||||
@@ -31,6 +32,7 @@ class FeedItemOut(_Out):
|
|||||||
reactions: ReactionsOut = ReactionsOut()
|
reactions: ReactionsOut = ReactionsOut()
|
||||||
bookmarked: bool = False
|
bookmarked: bool = False
|
||||||
poll: Optional[PollOut] = None
|
poll: Optional[PollOut] = None
|
||||||
|
project_link: Optional[ProjectLinkOut] = None
|
||||||
|
|
||||||
|
|
||||||
class GistItemOut(_Out):
|
class GistItemOut(_Out):
|
||||||
@@ -132,6 +134,7 @@ class PostDetailOut(_Out):
|
|||||||
comment_count: Optional[int] = None
|
comment_count: Optional[int] = None
|
||||||
related_posts: list[FeedItemOut] = []
|
related_posts: list[FeedItemOut] = []
|
||||||
topics: list[str] = []
|
topics: list[str] = []
|
||||||
|
project_link: Optional[ProjectLinkOut] = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectsOut(_Out):
|
class ProjectsOut(_Out):
|
||||||
|
|||||||
@@ -273,25 +273,4 @@ GAME_ACTIONS: tuple[Action, ...] = (
|
|||||||
requires_auth=True,
|
requires_auth=True,
|
||||||
params=(body("key", "An owned title cosmetic key.", required=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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from devplacepy.avatar import avatar_seed
|
from devplacepy.avatar import avatar_seed
|
||||||
|
from devplacepy.database import get_table
|
||||||
from devplacepy.services.devrant.avatar import avatar_payload
|
from devplacepy.services.devrant.avatar import avatar_payload
|
||||||
from devplacepy.services.devrant.ids import to_unix
|
from devplacepy.services.devrant.ids import to_unix
|
||||||
|
|
||||||
@@ -26,6 +27,22 @@ def encode_tags(tags: list) -> str:
|
|||||||
return json.dumps(cleaned)
|
return json.dumps(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def _rant_project(post: dict) -> dict | None:
|
||||||
|
project_uid = post.get("project_uid")
|
||||||
|
if not project_uid:
|
||||||
|
return None
|
||||||
|
project = get_table("projects").find_one(uid=project_uid)
|
||||||
|
if not project:
|
||||||
|
return None
|
||||||
|
slug = project.get("slug") or project["uid"]
|
||||||
|
return {
|
||||||
|
"uid": project["uid"],
|
||||||
|
"name": project.get("title") or project.get("name", ""),
|
||||||
|
"slug": slug,
|
||||||
|
"url": f"/projects/{slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def rant_text(post: dict) -> str:
|
def rant_text(post: dict) -> str:
|
||||||
title = (post.get("title") or "").strip()
|
title = (post.get("title") or "").strip()
|
||||||
content = post.get("content") or ""
|
content = post.get("content") or ""
|
||||||
@@ -56,6 +73,7 @@ def serialize_rant(
|
|||||||
author = authors.get(post["user_uid"]) or {}
|
author = authors.get(post["user_uid"]) or {}
|
||||||
uid = post["uid"]
|
uid = post["uid"]
|
||||||
username = author.get("username") or ""
|
username = author.get("username") or ""
|
||||||
|
project = _rant_project(post)
|
||||||
return {
|
return {
|
||||||
"id": int(post["id"]),
|
"id": int(post["id"]),
|
||||||
"text": rant_text(post),
|
"text": rant_text(post),
|
||||||
@@ -75,6 +93,8 @@ def serialize_rant(
|
|||||||
"user_avatar": avatar_payload(avatar_seed(author)),
|
"user_avatar": avatar_payload(avatar_seed(author)),
|
||||||
"user_avatar_lg": avatar_payload(avatar_seed(author)),
|
"user_avatar_lg": avatar_payload(avatar_seed(author)),
|
||||||
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
|
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
|
||||||
|
"project_uid": post.get("project_uid"),
|
||||||
|
"project": project,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -723,32 +723,6 @@ CANARY_DOUBLE_CHANCE = 0.12
|
|||||||
CANARY_FAIL_CHANCE = 0.06
|
CANARY_FAIL_CHANCE = 0.06
|
||||||
OBSERVABILITY_STEAL_CAP = 0.2
|
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:
|
def infra_for(key: str) -> Infrastructure | None:
|
||||||
return INFRA_BY_KEY.get(key)
|
return INFRA_BY_KEY.get(key)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ from .common import (
|
|||||||
steal_cooldown_remaining,
|
steal_cooldown_remaining,
|
||||||
)
|
)
|
||||||
from .cosmetics import buy_cosmetic, equip_title, owned_cosmetic_keys
|
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 .era import active_era, active_era_name, end_era, start_era
|
||||||
from .farm import (
|
from .farm import (
|
||||||
_create_plot,
|
_create_plot,
|
||||||
@@ -63,7 +63,7 @@ from .farm import (
|
|||||||
upgrade_ci,
|
upgrade_ci,
|
||||||
)
|
)
|
||||||
from .infrastructure import buy_infrastructure, owns_infrastructure
|
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 .mastery import upgrade_mastery
|
||||||
from .quests import advance_quests, ensure_quests
|
from .quests import advance_quests, ensure_quests
|
||||||
from .treasury import (
|
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:
|
def steal(thief: dict, owner: dict, slot: int) -> dict:
|
||||||
if thief["uid"] == owner["uid"]:
|
if thief["uid"] == owner["uid"]:
|
||||||
raise GameError("You cannot steal from your own farm.")
|
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"{owner.get('username', 'This farmer')} has already been raided "
|
||||||
f"{economy.STEAL_MAX_PER_VICTIM_PER_DAY} times today. Try again tomorrow."
|
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(
|
fraction = economy.effective_steal_fraction(
|
||||||
defense_level, floor, building_tier.steal_reduction, cap
|
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)
|
share = min(fraction, 1.0 - raided_fraction)
|
||||||
taken = conditional_update_row(
|
taken = conditional_update_row(
|
||||||
"game_plots",
|
"game_plots",
|
||||||
@@ -437,12 +336,6 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
|
|||||||
era_active=bool(active_era_name()),
|
era_active=bool(active_era_name()),
|
||||||
extra=underdog_extra or None,
|
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(
|
_steals().insert(
|
||||||
{
|
{
|
||||||
"uid": generate_uid(),
|
"uid": generate_uid(),
|
||||||
@@ -451,7 +344,6 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
|
|||||||
"slot_index": slot,
|
"slot_index": slot,
|
||||||
"crop_key": crop.key,
|
"crop_key": crop.key,
|
||||||
"coins": coins_gain,
|
"coins": coins_gain,
|
||||||
"insurance_payout": insurance_payout,
|
|
||||||
"stolen_at": _iso(now),
|
"stolen_at": _iso(now),
|
||||||
"created_at": _iso(now),
|
"created_at": _iso(now),
|
||||||
}
|
}
|
||||||
@@ -464,13 +356,6 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
|
|||||||
"owner_uid": owner["uid"],
|
"owner_uid": owner["uid"],
|
||||||
"share": round(share, 4),
|
"share": round(share, 4),
|
||||||
"underdog_triggered": bool(underdog_extra),
|
"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")
|
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:
|
def last_steal_at(thief_uid: str, owner_uid: str) -> datetime | None:
|
||||||
from devplacepy.database import db
|
from devplacepy.database import db
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from devplacepy.utils import generate_uid
|
|
||||||
|
|
||||||
from .. import economy
|
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
|
from .farm import ensure_farm, get_farm
|
||||||
|
|
||||||
|
|
||||||
@@ -140,74 +138,3 @@ def downgrade_defense(user: dict) -> dict:
|
|||||||
if rows == 0:
|
if rows == 0:
|
||||||
raise GameError("Defense already changed - refresh and try again.")
|
raise GameError("Defense already changed - refresh and try again.")
|
||||||
return {"defense_level": level - 1}
|
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}
|
text("DELETE FROM game_steals WHERE stolen_at < :cutoff"), {"cutoff": cutoff}
|
||||||
)
|
)
|
||||||
return result.rowcount
|
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 .. import economy
|
||||||
from .common import (
|
from .common import (
|
||||||
PERK_COLUMN,
|
PERK_COLUMN,
|
||||||
_defense_items,
|
|
||||||
_iso_week,
|
_iso_week,
|
||||||
_lvl,
|
_lvl,
|
||||||
_now,
|
_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(
|
def serialize_farm(
|
||||||
farm: dict, *, viewer: dict | None, owner: dict, now: datetime | None = None
|
farm: dict, *, viewer: dict | None, owner: dict, now: datetime | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -396,8 +368,6 @@ def serialize_farm(
|
|||||||
"era_name": era_name or "",
|
"era_name": era_name or "",
|
||||||
"era_coins": _lvl(farm, "era_coins"),
|
"era_coins": _lvl(farm, "era_coins"),
|
||||||
"era_harvests": _lvl(farm, "era_harvests"),
|
"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 {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,22 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.post-detail-actions {
|
.post-detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
<div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else ''), author_is_admin=is_admin(item.author)) }}</div>
|
<div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else ''), author_is_admin=is_admin(item.author)) }}</div>
|
||||||
|
|
||||||
|
{% if item.project_link %}
|
||||||
|
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if item.attachments %}
|
{% if item.attachments %}
|
||||||
{% include "_attachment_display.html" %}
|
{% include "_attachment_display.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
|
|
||||||
<div class="post-detail-content rendered-content">{{ render_content(post['content'], author_is_admin=is_admin(author)) }}</div>
|
<div class="post-detail-content rendered-content">{{ render_content(post['content'], author_is_admin=is_admin(author)) }}</div>
|
||||||
|
|
||||||
|
{% if project_link %}
|
||||||
|
<a href="{{ project_link.url }}" class="project-link">Project: {{ project_link.name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if attachments %}
|
{% if attachments %}
|
||||||
{% include "_attachment_display.html" %}
|
{% include "_attachment_display.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
from tests.conftest import BASE_URL
|
from tests.conftest import BASE_URL
|
||||||
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
||||||
|
from devplacepy.utils import generate_uid, make_combined_slug
|
||||||
|
|
||||||
_counter_dr = [0]
|
_counter_dr = [0]
|
||||||
|
|
||||||
@@ -46,10 +48,13 @@ def _register(password="secret123"):
|
|||||||
raise AssertionError("registration did not open")
|
raise AssertionError("registration did not open")
|
||||||
|
|
||||||
|
|
||||||
def _create_rant(params, text="this is a devrant rant body", tags="dev,test"):
|
def _create_rant(params, text="this is a devrant rant body", tags="dev,test", project_uid=None):
|
||||||
|
data = {**params, "rant": text, "tags": tags}
|
||||||
|
if project_uid:
|
||||||
|
data["project_uid"] = project_uid
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
f"{BASE_URL}/api/devrant/rants",
|
f"{BASE_URL}/api/devrant/rants",
|
||||||
data={**params, "rant": text, "tags": tags},
|
data=data,
|
||||||
)
|
)
|
||||||
return r
|
return r
|
||||||
|
|
||||||
@@ -235,3 +240,43 @@ def test_search_returns_results_shape(app_server):
|
|||||||
r = requests.get(f"{BASE_URL}/api/devrant/search", params={"term": "uniquesearchtoken"})
|
r = requests.get(f"{BASE_URL}/api/devrant/search", params={"term": "uniquesearchtoken"})
|
||||||
assert r.json()["success"] is True
|
assert r.json()["success"] is True
|
||||||
assert isinstance(r.json()["results"], list)
|
assert isinstance(r.json()["results"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rant_serialization_includes_project(app_server):
|
||||||
|
name, params = _register()
|
||||||
|
refresh_snapshot()
|
||||||
|
user = get_table("users").find_one(username=name)
|
||||||
|
uid = user["uid"]
|
||||||
|
project_uid = generate_uid()
|
||||||
|
slug = make_combined_slug("project-for-rant", project_uid)
|
||||||
|
projects = get_table("projects")
|
||||||
|
projects.insert({
|
||||||
|
"uid": project_uid,
|
||||||
|
"user_uid": uid,
|
||||||
|
"slug": slug,
|
||||||
|
"title": "Project For Rant",
|
||||||
|
"description": "project linked to a rant",
|
||||||
|
"project_type": "software",
|
||||||
|
"status": "In Development",
|
||||||
|
"stars": 0,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
})
|
||||||
|
refresh_snapshot()
|
||||||
|
rant_id = _create_rant(
|
||||||
|
params,
|
||||||
|
text="rant with a project link here",
|
||||||
|
tags="dev",
|
||||||
|
project_uid=project_uid,
|
||||||
|
).json()["rant_id"]
|
||||||
|
refresh_snapshot()
|
||||||
|
rant = requests.get(
|
||||||
|
f"{BASE_URL}/api/devrant/rants/{rant_id}",
|
||||||
|
params=params,
|
||||||
|
).json()["rant"]
|
||||||
|
assert rant.get("project_uid") == project_uid
|
||||||
|
assert rant.get("project") is not None
|
||||||
|
assert rant["project"]["uid"] == project_uid
|
||||||
|
assert rant["project"]["name"] == "Project For Rant"
|
||||||
|
assert "/projects/" in rant["project"]["url"]
|
||||||
|
|||||||
@@ -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)
|
r = session.post(f"{BASE_URL}/game/grant", headers=JSON)
|
||||||
assert r.status_code == 400
|
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
|
|
||||||
|
|||||||
@@ -716,3 +716,69 @@ def test_short_post_content_rejected(app_server):
|
|||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
assert "/posts/" not in (r.headers.get("location") or "")
|
assert "/posts/" not in (r.headers.get("location") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _new_project(session):
|
||||||
|
return session.post(
|
||||||
|
f"{BASE_URL}/projects/create",
|
||||||
|
headers=JSON_audit_log,
|
||||||
|
data={
|
||||||
|
"title": _unique("aupj"),
|
||||||
|
"description": "project for linked post test",
|
||||||
|
"project_type": "software",
|
||||||
|
"status": "In Development",
|
||||||
|
"platforms": "",
|
||||||
|
},
|
||||||
|
).json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_detail_includes_project_when_linked(app_server):
|
||||||
|
s, _ = _member()
|
||||||
|
project = _new_project(s)
|
||||||
|
project_uid = project["uid"]
|
||||||
|
created = s.post(
|
||||||
|
f"{BASE_URL}/posts/create",
|
||||||
|
headers=JSON_audit_log,
|
||||||
|
data={
|
||||||
|
"title": _unique("aupjpost"),
|
||||||
|
"content": "this post is linked to a project",
|
||||||
|
"topic": "devlog",
|
||||||
|
"project_uid": project_uid,
|
||||||
|
},
|
||||||
|
).json()["data"]
|
||||||
|
slug = created["slug"]
|
||||||
|
detail = s.get(
|
||||||
|
f"{BASE_URL}/posts/{slug}",
|
||||||
|
headers=JSON_audit_log,
|
||||||
|
).json()
|
||||||
|
assert detail.get("project_link") is not None, "linked project must appear in post detail"
|
||||||
|
assert detail["project_link"]["uid"] == project_uid
|
||||||
|
assert detail["project_link"]["name"] is not None
|
||||||
|
assert "/projects/" in detail["project_link"]["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_includes_project_when_linked(app_server):
|
||||||
|
s, _ = _member()
|
||||||
|
project = _new_project(s)
|
||||||
|
project_uid = project["uid"]
|
||||||
|
s.post(
|
||||||
|
f"{BASE_URL}/posts/create",
|
||||||
|
headers=JSON_audit_log,
|
||||||
|
data={
|
||||||
|
"title": _unique("aupjfeed"),
|
||||||
|
"content": "this post appears in feed with a project link",
|
||||||
|
"topic": "devlog",
|
||||||
|
"project_uid": project_uid,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
feed = s.get(
|
||||||
|
f"{BASE_URL}/feed",
|
||||||
|
headers=JSON_audit_log,
|
||||||
|
).json()
|
||||||
|
found = False
|
||||||
|
for item in feed["posts"]:
|
||||||
|
if item.get("project_link") is not None and item["project_link"]["uid"] == project_uid:
|
||||||
|
found = True
|
||||||
|
assert "/projects/" in item["project_link"]["url"]
|
||||||
|
break
|
||||||
|
assert found, "feed must include project info for linked posts"
|
||||||
|
|||||||
@@ -1177,145 +1177,3 @@ def test_claim_grant_partial_treasury(local_db):
|
|||||||
result = store.claim_grant(user)
|
result = store.claim_grant(user)
|
||||||
assert result["amount"] == 300
|
assert result["amount"] == 300
|
||||||
assert store.treasury_balance() == 0
|
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