Compare commits

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