forked from retoor/devplacepy
feat: add steal mechanic to Code Farm with 60s protection window and half-coin reward
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user