feat: add steal mechanic to Code Farm with 60s protection window and half-coin reward

This commit is contained in:
2026-06-22 17:33:03 +00:00
parent ebf6bf7f82
commit 412dbe3a3d
13 changed files with 302 additions and 14 deletions
+1
View File
@@ -2625,6 +2625,7 @@ NOTIFICATION_TYPES = [
{"key": "level", "label": "Level-ups", "description": "You reach a new level"},
{"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"},
{"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"},
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
]
NOTIFICATION_CHANNELS = ("in_app", "push")
+13
View File
@@ -5258,6 +5258,19 @@ client can refresh without a second request.
],
sample_response={"farm": {"owner_username": "alice"}},
),
endpoint(
id="game-steal",
method="POST",
path="/game/farm/{username}/steal",
title="Steal a build",
summary="Steal another player's ready build once its 60s protection window has passed; you receive half the build's coin value.",
auth="user",
params=[
field("username", "path", "string", True, "alice", "Farm owner's username."),
field("slot", "form", "integer", True, "0", "Plot slot index."),
],
sample_response={"farm": {"owner_username": "alice"}, "stole_coins": 18},
),
endpoint(
id="game-fertilize",
method="POST",
+43 -1
View File
@@ -9,7 +9,12 @@ from devplacepy.models import GameSlotForm
from devplacepy.responses import json_error, respond, wants_json
from devplacepy.schemas import GameFarmViewOut
from devplacepy.services.game import GameError, store
from devplacepy.utils import get_current_user, require_user, track_action
from devplacepy.utils import (
create_notification,
get_current_user,
require_user,
track_action,
)
from ._shared import game_seo, notify_farm, owner_by_username
@@ -65,3 +70,40 @@ async def water_farm(
)
return JSONResponse(GameFarmViewOut(farm=payload).model_dump(mode="json"))
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
@router.post("/farm/{username}/steal")
async def steal_farm(
request: Request, username: str, data: Annotated[GameSlotForm, Form()]
):
viewer = require_user(request)
owner = owner_by_username(username)
if not owner:
raise HTTPException(status_code=404, detail="Farm not found")
try:
result = store.steal(viewer, owner, data.slot)
except GameError as exc:
if wants_json(request):
return json_error(400, str(exc))
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
track_action(viewer["uid"], "harvest_stolen")
track_action(owner["uid"], "got_stolen_from")
create_notification(
owner["uid"],
"harvest_stolen",
"Someone raided your Code Farm and stole a ready build.",
viewer["uid"],
"/game",
)
await notify_farm(owner["username"])
await notify_farm(viewer["username"])
if wants_json(request):
payload = store.serialize_farm(
store.ensure_farm(owner["uid"]), viewer=viewer, owner=owner
)
return JSONResponse(
GameFarmViewOut(farm=payload, stole_coins=result["coins"]).model_dump(
mode="json"
)
)
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
+3
View File
@@ -1178,6 +1178,8 @@ class GamePlotOut(_Out):
watered_count: int = 0
max_waters: int = 0
can_water: bool = False
can_steal: bool = False
steal_coins: int = 0
fertilize_cost: int = 0
@@ -1246,6 +1248,7 @@ class GameFarmViewOut(_Out):
farm: GameFarmOut
page_title: str = ""
meta_description: str = ""
stole_coins: int = 0
class GameLeaderboardEntryOut(_Out):
@@ -1933,6 +1933,18 @@ ACTIONS: tuple[Action, ...] = (
Param(name="slot", location="body", description="Plot slot index.", required=True, type="integer"),
),
),
Action(
name="game_steal",
method="POST",
path="/game/farm/{username}/steal",
summary="Steal a ready build from another player's Code Farm once its 60s protection window has passed",
handler="http",
requires_auth=True,
params=(
path("username", "The farm owner's username."),
Param(name="slot", location="body", description="Plot slot index.", required=True, type="integer"),
),
),
Action(
name="game_fertilize",
method="POST",
+7
View File
@@ -15,6 +15,9 @@ MAX_WATERS_PER_PLOT = 3
WATER_REWARD_COINS = 6
WATER_REWARD_XP = 3
STEAL_GRACE_SECONDS = 60
STEAL_FRACTION = 0.5
@dataclass(frozen=True)
class Crop:
@@ -220,6 +223,10 @@ def effective_reward_xp(crop: Crop, xp_level: int = 0) -> int:
return round(crop.reward_xp * (1 + PERK_BY_KEY["xp"].step * xp_level))
def steal_reward_coins(crop: Crop, yield_level: int = 0, prestige: int = 0) -> int:
return max(1, round(effective_reward_coins(crop, yield_level, prestige) * STEAL_FRACTION))
def daily_reward(streak: int) -> int:
effective = min(max(streak, 1), DAILY_STREAK_CAP)
return DAILY_BASE + DAILY_STREAK_STEP * (effective - 1)
+77 -6
View File
@@ -133,7 +133,15 @@ def _plot_state(plot: dict, now: datetime) -> str:
return "growing"
def serialize_plot(plot: dict, *, viewer_uid: str, owner_uid: str, now: datetime) -> dict:
def serialize_plot(
plot: dict,
*,
viewer_uid: str,
owner_uid: str,
now: datetime,
yield_level: int = 0,
prestige: int = 0,
) -> dict:
state = _plot_state(plot, now)
crop = economy.crop_for(plot.get("crop_key", ""))
ready_at = _parse(plot.get("ready_at", ""))
@@ -149,6 +157,17 @@ def serialize_plot(plot: dict, *, viewer_uid: str, owner_uid: str, now: datetime
and viewer_uid not in watered
and len(watered) < economy.MAX_WATERS_PER_PLOT
)
can_steal = (
state == "ready"
and not is_owner
and bool(viewer_uid)
and bool(crop)
and ready_at is not None
and now >= ready_at + timedelta(seconds=economy.STEAL_GRACE_SECONDS)
)
steal_coins = (
economy.steal_reward_coins(crop, yield_level, prestige) if can_steal else 0
)
return {
"slot": plot.get("slot_index", 0),
"state": state,
@@ -162,6 +181,8 @@ def serialize_plot(plot: dict, *, viewer_uid: str, owner_uid: str, now: datetime
"watered_count": len(watered),
"max_waters": economy.MAX_WATERS_PER_PLOT,
"can_water": can_water,
"can_steal": can_steal,
"steal_coins": steal_coins,
"fertilize_cost": (
economy.fertilize_cost(remaining) if state == "growing" and is_owner else 0
),
@@ -174,9 +195,20 @@ def serialize_farm(
now = now or _now()
viewer_uid = viewer["uid"] if viewer else ""
owner_uid = owner["uid"]
prestige = _lvl(farm, "prestige")
growth_level = _lvl(farm, "perk_growth")
discount_level = _lvl(farm, "perk_discount")
yield_level = _lvl(farm, "perk_yield")
plots = get_plots(farm["uid"])
serialized_plots = [
serialize_plot(plot, viewer_uid=viewer_uid, owner_uid=owner_uid, now=now)
serialize_plot(
plot,
viewer_uid=viewer_uid,
owner_uid=owner_uid,
now=now,
yield_level=yield_level,
prestige=prestige,
)
for plot in plots
]
progress = economy.level_progress(int(farm.get("xp", 0)))
@@ -185,10 +217,6 @@ def serialize_farm(
next_tier = economy.next_ci_tier(ci_tier)
ci_entry = economy.CI_BY_TIER.get(ci_tier)
is_owner = viewer_uid == owner_uid
prestige = _lvl(farm, "prestige")
growth_level = _lvl(farm, "perk_growth")
discount_level = _lvl(farm, "perk_discount")
yield_level = _lvl(farm, "perk_yield")
streak = _lvl(farm, "streak")
daily_available = is_owner and _daily_available(farm, now)
return {
@@ -401,6 +429,49 @@ def water(visitor: dict, owner: dict, slot: int) -> dict:
}
def steal(thief: dict, owner: dict, slot: int) -> dict:
if thief["uid"] == owner["uid"]:
raise GameError("You cannot steal from your own farm.")
farm = ensure_farm(owner["uid"])
plot = _plot_at(farm["uid"], slot)
if not plot or not plot.get("crop_key"):
raise GameError("That plot is empty.")
now = _now()
if _plot_state(plot, now) != "ready":
raise GameError("That build is not ready.")
crop = economy.crop_for(plot.get("crop_key", ""))
if not crop:
raise GameError("Unknown crop type.")
ready_at = _parse(plot.get("ready_at", "")) or now
if now < ready_at + timedelta(seconds=economy.STEAL_GRACE_SECONDS):
raise GameError("That harvest is still protected.")
_plots().update(
{
"uid": plot["uid"],
"crop_key": "",
"planted_at": "",
"ready_at": "",
"watered_by": "[]",
"updated_at": _iso(now),
},
["uid"],
)
coins_gain = economy.steal_reward_coins(
crop, _lvl(farm, "perk_yield"), _lvl(farm, "prestige")
)
thief_farm = ensure_farm(thief["uid"])
_update_farm(
thief_farm["uid"],
{"coins": int(thief_farm.get("coins", 0)) + coins_gain},
)
return {
"slot": slot,
"crop": crop.key,
"coins": coins_gain,
"owner_uid": owner["uid"],
}
def leaderboard(limit: int = 25) -> list[dict]:
farms = sorted(
_farms().find(),
+6 -1
View File
@@ -50,7 +50,10 @@ export class GameFarm {
try {
const data = await Http.send(form.getAttribute("action"), params);
this.render(data.farm);
if (action === "harvest" || action === "water" || action === "prestige") {
if (action === "steal" && window.app && window.app.toast) {
window.app.toast.show(`You stole ${data.stole_coins}c!`, { type: "success" });
}
if (action === "harvest" || action === "water" || action === "steal" || action === "prestige") {
this._loadLeaderboard();
}
} catch (error) {
@@ -188,6 +191,8 @@ export class GameFarm {
body = `<span class="plot-icon plot-ready-icon" aria-hidden="true">${plot.crop_icon}</span><span class="plot-crop-name">${plot.crop_name}</span>`;
if (farm.is_owner) {
body += `<form method="post" action="/game/harvest" data-game-action="harvest"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm btn-primary">Harvest +${plot.reward_coins}c</button></form>`;
} else if (plot.can_steal) {
body += `<form method="post" action="/game/farm/${farm.owner_username}/steal" data-game-action="steal"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm btn-danger" data-confirm="Steal this ready build?">Steal +${plot.steal_coins}c</button></form>`;
} else {
body += `<span class="plot-ready-label">Ready</span>`;
}
+5
View File
@@ -40,6 +40,11 @@
<input type="hidden" name="slot" value="{{ plot.slot }}">
<button type="submit" class="btn btn-sm btn-primary">Harvest +{{ plot.reward_coins }}c</button>
</form>
{% elif plot.can_steal %}
<form method="post" action="/game/farm/{{ farm.owner_username }}/steal" data-game-action="steal">
<input type="hidden" name="slot" value="{{ plot.slot }}">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Steal this ready build?">Steal +{{ plot.steal_coins }}c</button>
</form>
{% else %}
<span class="plot-ready-label">Ready</span>
{% endif %}
+4
View File
@@ -691,6 +691,8 @@ BADGE_CATALOG = {
"Green Thumb": {"icon": "🌱", "description": "Harvested your first build on the Code Farm", "group": "Code Farm"},
"Master Farmer": {"icon": "🌾", "description": "Harvested 50 builds on the Code Farm", "group": "Code Farm"},
"Good Neighbor": {"icon": "💧", "description": "Watered a neighbour's build", "group": "Code Farm"},
"Cat Burglar": {"icon": "🥷", "description": "Stole a ready build from another farm", "group": "Code Farm"},
"Robbed": {"icon": "🚨", "description": "Had a build stolen from your farm", "group": "Code Farm"},
}
BADGE_GROUPS = [
@@ -921,6 +923,8 @@ ACHIEVEMENTS = {
"profile": [(1, "Profiled")],
"harvest": [(1, "Green Thumb"), (50, "Master Farmer")],
"water": [(1, "Good Neighbor")],
"harvest_stolen": [(1, "Cat Burglar")],
"got_stolen_from": [(1, "Robbed")],
}
UNIQUE_ACTIONS = {"docs.read"}