forked from retoor/devplacepy
feat: add per-user avatar seed regeneration with irreversible random avatar replacement
Implement a new `avatar_seed` column on the users table that overrides the username-based seed for Multiavatar generation. Introduce a null-safe `avatar_seed(user)` choke point in `avatar.py` that resolves `user.get("avatar_seed") or user.get("username")`, registered as a Jinja global so every render site (`_avatar_link.html`, `avatar_url(...)` calls, SEO `og_image`, issues ad-hoc dicts, devRant payload/PNG) propagates a regenerated seed. Add `POST /profile/{username}/regenerate-avatar` endpoint (owner-or-admin only) that writes a fresh `generate_uid()` to `avatar_seed`, invalidates the target's user cache, and audits `profile.avatar.regenerate`. The previous seed is overwritten and never stored, making regeneration irreversible. Document the feature in `AGENTS.md` and `README.md`, add the API endpoint to `docs_api.py`, and include the `regenerate_avatar` Devii tool in `CONFIRM_REQUIRED`.
This commit is contained in:
@@ -9,6 +9,12 @@ def avatar_url(style: str, seed: str, size: int = 128) -> str:
|
||||
return f"/avatar/{style}/{seed}?size={size}"
|
||||
|
||||
|
||||
def avatar_seed(user) -> str:
|
||||
if not user:
|
||||
return ""
|
||||
return user.get("avatar_seed") or user.get("username") or ""
|
||||
|
||||
|
||||
def generate_avatar_svg(seed: str) -> str:
|
||||
try:
|
||||
from multiavatar.multiavatar import multiavatar
|
||||
|
||||
@@ -1277,6 +1277,12 @@ def init_db():
|
||||
("perk_growth", 0),
|
||||
("perk_discount", 0),
|
||||
("perk_xp", 0),
|
||||
("stars", 0),
|
||||
("legacy_autoharvest", 0),
|
||||
("legacy_multiplier", 0),
|
||||
("legacy_speed", 0),
|
||||
("legacy_plots", 0),
|
||||
("legacy_defense", 0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
@@ -1285,6 +1291,23 @@ def init_db():
|
||||
_index(db, "game_farms", "idx_game_farms_user", ["user_uid"], unique=True)
|
||||
_index(db, "game_farms", "idx_game_farms_rank", ["level", "xp"])
|
||||
|
||||
game_steals = get_table("game_steals")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("thief_uid", ""),
|
||||
("owner_uid", ""),
|
||||
("slot_index", 0),
|
||||
("crop_key", ""),
|
||||
("coins", 0),
|
||||
("stolen_at", ""),
|
||||
("created_at", ""),
|
||||
):
|
||||
if not game_steals.has_column(column):
|
||||
game_steals.create_column_by_example(column, example)
|
||||
_index(
|
||||
db, "game_steals", "idx_game_steals_pair", ["thief_uid", "owner_uid", "stolen_at"]
|
||||
)
|
||||
|
||||
game_quests = get_table("game_quests")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
@@ -1566,6 +1589,8 @@ def backfill_api_keys() -> int:
|
||||
users.create_column_by_example("ai_modifier_prompt", DEFAULT_MODIFIER_PROMPT)
|
||||
if not users.has_column("timezone"):
|
||||
users.create_column_by_example("timezone", "")
|
||||
if not users.has_column("avatar_seed"):
|
||||
users.create_column_by_example("avatar_seed", "")
|
||||
with db:
|
||||
db.query(
|
||||
"UPDATE users SET ai_modifier_enabled = 1 WHERE ai_modifier_enabled IS NULL"
|
||||
@@ -3246,6 +3271,30 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
|
||||
return "/feed"
|
||||
|
||||
|
||||
def mark_notifications_read_by_target(user_uid: str, target_url: str) -> int:
|
||||
if not user_uid or not target_url or "notifications" not in db.tables:
|
||||
return 0
|
||||
notifications_table = get_table("notifications")
|
||||
ids = [
|
||||
n["id"]
|
||||
for n in notifications_table.find(user_uid=user_uid, read=False)
|
||||
if n.get("target_url")
|
||||
and (
|
||||
n["target_url"] == target_url
|
||||
or n["target_url"].startswith(f"{target_url}#")
|
||||
)
|
||||
]
|
||||
if not ids:
|
||||
return 0
|
||||
with db:
|
||||
for notification_id in ids:
|
||||
notifications_table.update({"id": notification_id, "read": True}, ["id"])
|
||||
from devplacepy.templating import clear_unread_cache
|
||||
|
||||
clear_unread_cache(user_uid)
|
||||
return len(ids)
|
||||
|
||||
|
||||
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
|
||||
table_name = VOTABLE_TARGETS.get(target_type)
|
||||
if not table_name:
|
||||
|
||||
+42
-3
@@ -1844,6 +1844,35 @@ four ways to sign requests.
|
||||
],
|
||||
sample_response={"api_key": "NEW_UUID"},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-regenerate-avatar",
|
||||
method="POST",
|
||||
path="/profile/{username}/regenerate-avatar",
|
||||
title="Regenerate a user avatar",
|
||||
summary="Replace the user's avatar with a freshly generated random one.",
|
||||
auth="user",
|
||||
interactive=False,
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
required=True,
|
||||
description="Profile owner. Allowed for the owner or any admin.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"> Irreversible: the previous avatar is gone for good and cannot be brought back.",
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"data": {
|
||||
"url": "/profile/{{ username }}",
|
||||
"avatar_seed": "NEW_UUID",
|
||||
"avatar_url": "/avatar/multiavatar/NEW_UUID?size=80",
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-customization-global",
|
||||
method="POST",
|
||||
@@ -5263,7 +5292,7 @@ client can refresh without a second request.
|
||||
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.",
|
||||
summary="Steal another player's ready build once its protection window has passed; you receive half the build's coin value. Limited to once per hour per neighbour.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("username", "path", "string", True, "alice", "Farm owner's username."),
|
||||
@@ -5276,7 +5305,7 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/fertilize",
|
||||
title="Fertilize a build",
|
||||
summary="Spend coins to halve a growing build's remaining time.",
|
||||
summary="Spend coins to halve a growing build's remaining time. The cost scales with the build's realized harvest value, so fertilizing is a pure time-skip and never a profit at any prestige.",
|
||||
auth="user",
|
||||
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
|
||||
sample_response={"ok": True, "farm": {"coins": 12}},
|
||||
@@ -5315,11 +5344,21 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/prestige",
|
||||
title="Refactor (prestige)",
|
||||
summary="Reset the farm at level 10+ for a permanent +25% coin bonus.",
|
||||
summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
sample_response={"ok": True, "farm": {"prestige": 1}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-legacy",
|
||||
method="POST",
|
||||
path="/game/legacy",
|
||||
title="Buy a Legacy upgrade",
|
||||
summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest, multiplier, speed, plots, or defense.",
|
||||
auth="user",
|
||||
params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")],
|
||||
sample_response={"ok": True, "farm": {"stars": 1}},
|
||||
),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -547,3 +547,7 @@ class GamePerkForm(BaseModel):
|
||||
|
||||
class GameQuestForm(BaseModel):
|
||||
quest: str = Field(min_length=1, max_length=40)
|
||||
|
||||
|
||||
class GameLegacyForm(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=40)
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.constants import DEVII_GUEST_COOKIE
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.database import get_int_setting, mark_notifications_read_by_target
|
||||
from devplacepy.seo import base_seo_context, site_url
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
@@ -77,6 +77,8 @@ def _owner_from_request(request: Request):
|
||||
@router.get("/")
|
||||
async def devii_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
if user:
|
||||
mark_notifications_read_by_target(user["uid"], "/devii")
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Devii",
|
||||
|
||||
@@ -6,11 +6,13 @@ from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy.models import (
|
||||
GameLegacyForm,
|
||||
GamePerkForm,
|
||||
GamePlantForm,
|
||||
GameQuestForm,
|
||||
GameSlotForm,
|
||||
)
|
||||
from devplacepy.database import mark_notifications_read_by_target
|
||||
from devplacepy.responses import json_error, respond, wants_json
|
||||
from devplacepy.schemas import GameLeaderboardOut, GameStateOut
|
||||
from devplacepy.services.game import GameError, store
|
||||
@@ -24,6 +26,7 @@ router = APIRouter()
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def game_home(request: Request):
|
||||
user = require_user(request)
|
||||
mark_notifications_read_by_target(user["uid"], "/game")
|
||||
farm = state_payload(user)
|
||||
seo_ctx = game_seo(
|
||||
request,
|
||||
@@ -130,6 +133,14 @@ async def game_prestige(request: Request):
|
||||
return await _respond_action(request, user, lambda: store.prestige(user))
|
||||
|
||||
|
||||
@router.post("/legacy")
|
||||
async def game_legacy(request: Request, data: Annotated[GameLegacyForm, Form()]):
|
||||
user = require_user(request)
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.upgrade_legacy(user, data.key)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/quests/claim")
|
||||
async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
@@ -12,6 +12,8 @@ from devplacepy.database import (
|
||||
get_recent_comments_by_target_uids,
|
||||
paginate,
|
||||
text_search_clause,
|
||||
resolve_object_url,
|
||||
mark_notifications_read_by_target,
|
||||
)
|
||||
from devplacepy.content import (
|
||||
load_detail,
|
||||
@@ -153,6 +155,10 @@ async def gist_detail(request: Request, gist_slug: str):
|
||||
redirect = canonical_redirect("gists", gist, gist_slug)
|
||||
if redirect:
|
||||
return redirect
|
||||
if user:
|
||||
mark_notifications_read_by_target(
|
||||
user["uid"], resolve_object_url("gist", gist["uid"])
|
||||
)
|
||||
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
|
||||
@@ -21,7 +21,7 @@ def author_fields(author_uid: str | None, users_map: dict, fallback_login: str)
|
||||
return {
|
||||
"author_username": user["username"],
|
||||
"author_uid": author_uid,
|
||||
"author_avatar_seed": user["username"],
|
||||
"author_avatar_seed": user.get("avatar_seed") or user["username"],
|
||||
"is_local_author": True,
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,12 @@ from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.attachments import get_attachments, get_attachments_batch
|
||||
from devplacepy.database import build_pagination, get_users_by_uids, get_blocked_uids
|
||||
from devplacepy.database import (
|
||||
build_pagination,
|
||||
get_users_by_uids,
|
||||
get_blocked_uids,
|
||||
mark_notifications_read_by_target,
|
||||
)
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import IssueDetailOut, IssuesOut
|
||||
from devplacepy.services.gitea import runtime, store
|
||||
@@ -101,6 +106,9 @@ async def issue_detail(request: Request, number: int):
|
||||
logger.warning("Could not load comments for issue #%s: %s", number, exc)
|
||||
comments = []
|
||||
|
||||
if user:
|
||||
mark_notifications_read_by_target(user["uid"], f"/issues?highlight={number}")
|
||||
|
||||
author_uid = store.author_uid_for_issue(number)
|
||||
comment_authors = store.comment_author_map(
|
||||
[int(comment.get("id", 0)) for comment in comments]
|
||||
|
||||
@@ -12,6 +12,7 @@ from devplacepy.database import (
|
||||
get_users_by_uids,
|
||||
search_users_by_username,
|
||||
get_blocked_uids,
|
||||
mark_notifications_read_by_target,
|
||||
)
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.templating import clear_messages_cache
|
||||
@@ -149,6 +150,9 @@ async def messages_page(request: Request, with_uid: str = None, search: str = ""
|
||||
if with_uid:
|
||||
messages, other_user = get_conversation_messages(user["uid"], with_uid)
|
||||
mark_conversation_read(user["uid"], with_uid)
|
||||
mark_notifications_read_by_target(
|
||||
user["uid"], f"/messages?with_uid={with_uid}"
|
||||
)
|
||||
current_conversation = with_uid
|
||||
other_online = message_hub.is_online(with_uid)
|
||||
other_last_seen = message_hub.last_seen(with_uid)
|
||||
|
||||
@@ -13,6 +13,8 @@ from devplacepy.database import (
|
||||
get_recent_comments_by_target_uids,
|
||||
get_user_bookmarks,
|
||||
paginate,
|
||||
resolve_object_url,
|
||||
mark_notifications_read_by_target,
|
||||
)
|
||||
from devplacepy.utils import get_current_user, time_ago, not_found
|
||||
from devplacepy.content import canonical_redirect
|
||||
@@ -104,6 +106,10 @@ async def news_detail_page(request: Request, news_slug: str):
|
||||
redirect = canonical_redirect("news", article, news_slug)
|
||||
if redirect:
|
||||
return redirect
|
||||
if user:
|
||||
mark_notifications_read_by_target(
|
||||
user["uid"], resolve_object_url("news", article["uid"])
|
||||
)
|
||||
|
||||
image_url = article.get("image_url", "") or ""
|
||||
if not image_url and "news_images" in db.tables:
|
||||
|
||||
@@ -6,7 +6,13 @@ from datetime import datetime, timezone
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from devplacepy.constants import TOPICS
|
||||
from devplacepy.database import db, get_table, resolve_by_slug
|
||||
from devplacepy.database import (
|
||||
db,
|
||||
get_table,
|
||||
resolve_by_slug,
|
||||
resolve_object_url,
|
||||
mark_notifications_read_by_target,
|
||||
)
|
||||
from devplacepy.utils import (
|
||||
get_current_user,
|
||||
require_user,
|
||||
@@ -138,6 +144,10 @@ async def view_post(request: Request, post_slug: str):
|
||||
redirect = canonical_redirect("posts", post, post_slug)
|
||||
if redirect:
|
||||
return redirect
|
||||
if user:
|
||||
mark_notifications_read_by_target(
|
||||
user["uid"], resolve_object_url("post", post["uid"])
|
||||
)
|
||||
author = detail["author"]
|
||||
top_level = detail["comments"]
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter
|
||||
from devplacepy.routers.profile import (
|
||||
ai_correction,
|
||||
ai_modifier,
|
||||
avatar,
|
||||
customization,
|
||||
index,
|
||||
notifications,
|
||||
@@ -18,6 +19,7 @@ router.include_router(customization.router)
|
||||
router.include_router(notifications.router)
|
||||
router.include_router(ai_correction.router)
|
||||
router.include_router(ai_modifier.router)
|
||||
router.include_router(avatar.router)
|
||||
router.include_router(telegram.router)
|
||||
|
||||
__all__ = ["router", "_ai_quota"]
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from devplacepy.avatar import avatar_url
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.responses import action_result
|
||||
from devplacepy.utils import clear_user_cache, generate_uid
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
from devplacepy.routers.profile._shared import resolve_customization_target
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/{username}/regenerate-avatar")
|
||||
async def regenerate_avatar(request: Request, username: str):
|
||||
target, denied = resolve_customization_target(request, username)
|
||||
if denied is not None:
|
||||
return denied
|
||||
seed = generate_uid()
|
||||
get_table("users").update({"uid": target["uid"], "avatar_seed": seed}, ["uid"])
|
||||
clear_user_cache(target["uid"])
|
||||
logger.info(f"Avatar regenerated for {target['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
"profile.avatar.regenerate",
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=target["username"],
|
||||
new_value=seed,
|
||||
summary=f"regenerated avatar for {target['username']}",
|
||||
links=[audit.target("user", target["uid"], target["username"])],
|
||||
)
|
||||
url = f"/profile/{target['username']}"
|
||||
return action_result(
|
||||
request,
|
||||
url,
|
||||
data={
|
||||
"url": url,
|
||||
"avatar_seed": seed,
|
||||
"avatar_url": avatar_url("multiavatar", seed, 80),
|
||||
},
|
||||
)
|
||||
@@ -25,6 +25,7 @@ from devplacepy.database import (
|
||||
get_user_relations,
|
||||
get_user_media,
|
||||
search_users_by_username,
|
||||
mark_notifications_read_by_target,
|
||||
)
|
||||
from devplacepy.content import can_view_project, enrich_items
|
||||
from devplacepy.utils import (
|
||||
@@ -40,7 +41,7 @@ from devplacepy.utils import (
|
||||
)
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import ProfileOut
|
||||
from devplacepy.avatar import avatar_url
|
||||
from devplacepy.avatar import avatar_url, avatar_seed
|
||||
from devplacepy.seo import (
|
||||
base_seo_context,
|
||||
site_url,
|
||||
@@ -120,6 +121,10 @@ async def profile_page(
|
||||
profile_user = users.find_one(username=username)
|
||||
if not profile_user:
|
||||
raise not_found("Profile not found")
|
||||
if current_user:
|
||||
mark_notifications_read_by_target(
|
||||
current_user["uid"], f"/profile/{profile_user['username']}"
|
||||
)
|
||||
profile_user["stars"] = get_user_stars(profile_user["uid"])
|
||||
rank = get_user_rank(profile_user["uid"])
|
||||
follow_counts = get_follow_counts(profile_user["uid"])
|
||||
@@ -321,7 +326,7 @@ async def profile_page(
|
||||
description=desc,
|
||||
robots=robots,
|
||||
og_type="profile",
|
||||
og_image=avatar_url("multiavatar", profile_user["username"], 256),
|
||||
og_image=avatar_url("multiavatar", avatar_seed(profile_user), 256),
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{
|
||||
|
||||
@@ -16,6 +16,8 @@ from devplacepy.database import (
|
||||
paginate,
|
||||
text_search_clause,
|
||||
resolve_by_slug,
|
||||
resolve_object_url,
|
||||
mark_notifications_read_by_target,
|
||||
get_fork_parent,
|
||||
count_forks,
|
||||
get_top_authors,
|
||||
@@ -178,6 +180,10 @@ async def project_detail(request: Request, project_slug: str):
|
||||
redirect = canonical_redirect("projects", project, project_slug)
|
||||
if redirect:
|
||||
return redirect
|
||||
if user:
|
||||
mark_notifications_read_by_target(
|
||||
user["uid"], resolve_object_url("project", project["uid"])
|
||||
)
|
||||
|
||||
base = site_url(request)
|
||||
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
|
||||
|
||||
@@ -1181,6 +1181,9 @@ class GamePlotOut(_Out):
|
||||
can_water: bool = False
|
||||
can_steal: bool = False
|
||||
steal_coins: int = 0
|
||||
steal_cooldown_seconds: int = 0
|
||||
steal_reason: str = ""
|
||||
is_golden: bool = False
|
||||
fertilize_cost: int = 0
|
||||
|
||||
|
||||
@@ -1196,6 +1199,18 @@ class GamePerkOut(_Out):
|
||||
effect: str = ""
|
||||
|
||||
|
||||
class GameLegacyOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
description: str = ""
|
||||
level: int = 0
|
||||
max_level: int = 0
|
||||
cost: int = 0
|
||||
maxed: bool = False
|
||||
effect: str = ""
|
||||
|
||||
|
||||
class GameQuestOut(_Out):
|
||||
kind: str = ""
|
||||
label: str = ""
|
||||
@@ -1238,6 +1253,9 @@ class GameFarmOut(_Out):
|
||||
daily_reward: int = 0
|
||||
perks: list[GamePerkOut] = []
|
||||
quests: list[GameQuestOut] = []
|
||||
stars: int = 0
|
||||
legacy: list[GameLegacyOut] = []
|
||||
steal_cooldown_seconds: int = 0
|
||||
|
||||
|
||||
class GameStateOut(_Out):
|
||||
|
||||
@@ -724,6 +724,21 @@ ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="regenerate_avatar",
|
||||
method="POST",
|
||||
path="/profile/{username}/regenerate-avatar",
|
||||
summary="Generate a new random avatar for a user",
|
||||
description=(
|
||||
"Replaces the user's avatar with a freshly generated random one and returns the new "
|
||||
"avatar_url. Owner-or-admin only. Irreversible: the previous avatar is gone for good, "
|
||||
"so confirm with the user before calling it."
|
||||
),
|
||||
params=(
|
||||
path("username", "Username whose avatar to regenerate."),
|
||||
confirm(),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="list_messages",
|
||||
method="GET",
|
||||
@@ -1937,7 +1952,7 @@ ACTIONS: tuple[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",
|
||||
summary="Steal a ready build from another player's Code Farm once its protection window has passed (limited to once per hour per neighbour)",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
@@ -1949,7 +1964,7 @@ ACTIONS: tuple[Action, ...] = (
|
||||
name="game_fertilize",
|
||||
method="POST",
|
||||
path="/game/fertilize",
|
||||
summary="Spend coins to halve a growing build's remaining time on your Code Farm",
|
||||
summary="Spend coins to halve a growing build's remaining time on your Code Farm (cost scales with the build's harvest value, so it is a pure time-skip, never a profit)",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
@@ -1990,10 +2005,25 @@ ACTIONS: tuple[Action, ...] = (
|
||||
name="game_prestige",
|
||||
method="POST",
|
||||
path="/game/prestige",
|
||||
summary="Refactor (prestige) your Code Farm for a permanent coin bonus (requires level 10)",
|
||||
summary="Refactor (prestige) your Code Farm for a permanent coin bonus and Stars (requires level 10)",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
),
|
||||
Action(
|
||||
name="game_upgrade_legacy",
|
||||
method="POST",
|
||||
path="/game/legacy",
|
||||
summary="Spend Stars on a permanent Legacy upgrade that survives refactor (autoharvest, multiplier, speed, plots, defense)",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
body(
|
||||
"key",
|
||||
"Legacy key: autoharvest, multiplier, speed, plots, or defense.",
|
||||
required=True,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
|
||||
|
||||
@@ -41,6 +41,7 @@ CONFIRM_REQUIRED = {
|
||||
"delete_project",
|
||||
"delete_media",
|
||||
"regenerate_api_key",
|
||||
"regenerate_avatar",
|
||||
"delete_post",
|
||||
"delete_comment",
|
||||
"delete_gist",
|
||||
|
||||
@@ -9,20 +9,20 @@ from devplacepy.avatar import generate_avatar_svg
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def background_color(username: Optional[str]) -> str:
|
||||
seed = username or "?"
|
||||
return hashlib.md5(seed.encode("utf-8")).hexdigest()[:6]
|
||||
def background_color(seed: Optional[str]) -> str:
|
||||
value = seed or "?"
|
||||
return hashlib.md5(value.encode("utf-8")).hexdigest()[:6]
|
||||
|
||||
|
||||
def avatar_payload(username: Optional[str]) -> dict:
|
||||
seed = username or "anonymous"
|
||||
return {"b": background_color(seed), "i": f"u/{seed}.png"}
|
||||
def avatar_payload(seed: Optional[str]) -> dict:
|
||||
value = seed or "anonymous"
|
||||
return {"b": background_color(value), "i": f"u/{value}.png"}
|
||||
|
||||
|
||||
def render_png(username: str, size: int = 128) -> bytes:
|
||||
def render_png(seed: str, size: int = 128) -> bytes:
|
||||
import cairosvg
|
||||
|
||||
svg = generate_avatar_svg(username)
|
||||
svg = generate_avatar_svg(seed)
|
||||
return cairosvg.svg2png(
|
||||
bytestring=svg.encode("utf-8"), output_width=size, output_height=size
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from devplacepy.database import (
|
||||
get_vote_counts,
|
||||
get_user_stars,
|
||||
)
|
||||
from devplacepy.avatar import avatar_seed
|
||||
from devplacepy.services.devrant.avatar import avatar_payload
|
||||
from devplacepy.services.devrant.feed import build_rant_list
|
||||
from devplacepy.services.devrant.ids import to_unix
|
||||
@@ -88,7 +89,7 @@ def build_profile(user: dict, viewer: Optional[dict]) -> dict:
|
||||
"skills": skills_from_bio(bio),
|
||||
"github": user.get("git_link") or "",
|
||||
"website": user.get("website") or "",
|
||||
"avatar": avatar_payload(user.get("username")),
|
||||
"avatar": avatar_payload(avatar_seed(user)),
|
||||
"content": {
|
||||
"content": {
|
||||
"rants": rants,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.avatar import avatar_seed
|
||||
from devplacepy.services.devrant.avatar import avatar_payload
|
||||
from devplacepy.services.devrant.ids import to_unix
|
||||
|
||||
@@ -71,8 +72,8 @@ def serialize_rant(
|
||||
"user_id": int(author.get("id") or 0),
|
||||
"user_username": username,
|
||||
"user_score": int(user_scores.get(post["user_uid"], 0)),
|
||||
"user_avatar": avatar_payload(username),
|
||||
"user_avatar_lg": avatar_payload(username),
|
||||
"user_avatar": avatar_payload(avatar_seed(author)),
|
||||
"user_avatar_lg": avatar_payload(avatar_seed(author)),
|
||||
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
|
||||
}
|
||||
|
||||
@@ -99,5 +100,5 @@ def serialize_comment(
|
||||
"user_id": int(author.get("id") or 0),
|
||||
"user_username": username,
|
||||
"user_score": int(user_scores.get(comment["user_uid"], 0)),
|
||||
"user_avatar": avatar_payload(username),
|
||||
"user_avatar": avatar_payload(avatar_seed(author)),
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
STARTING_COINS = 50
|
||||
@@ -17,6 +19,10 @@ WATER_REWARD_XP = 3
|
||||
|
||||
STEAL_GRACE_SECONDS = 60
|
||||
STEAL_FRACTION = 0.5
|
||||
STEAL_COOLDOWN_SECONDS = 3600
|
||||
|
||||
GOLDEN_CHANCE = 0.05
|
||||
GOLDEN_MULTIPLIER = 5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -77,16 +83,32 @@ def next_ci_tier(tier: int) -> CiTier | None:
|
||||
return CI_BY_TIER.get(tier + 1)
|
||||
|
||||
|
||||
def farm_speed(ci_tier: int, growth_level: int = 0) -> float:
|
||||
return ci_speed(ci_tier) * (1 + PERK_BY_KEY["growth"].step * growth_level)
|
||||
def farm_speed(ci_tier: int, growth_level: int = 0, legacy_speed_level: int = 0) -> float:
|
||||
return (
|
||||
ci_speed(ci_tier)
|
||||
* (1 + PERK_BY_KEY["growth"].step * growth_level)
|
||||
* (1 + LEGACY_SPEED_STEP * legacy_speed_level)
|
||||
)
|
||||
|
||||
|
||||
def grow_seconds_for(crop: Crop, ci_tier: int, growth_level: int = 0) -> int:
|
||||
return max(1, round(crop.grow_seconds / farm_speed(ci_tier, growth_level)))
|
||||
def grow_seconds_for(
|
||||
crop: Crop, ci_tier: int, growth_level: int = 0, legacy_speed_level: int = 0
|
||||
) -> int:
|
||||
return max(
|
||||
1, round(crop.grow_seconds / farm_speed(ci_tier, growth_level, legacy_speed_level))
|
||||
)
|
||||
|
||||
|
||||
def water_bonus_seconds(crop: Crop, ci_tier: int, growth_level: int = 0) -> int:
|
||||
return max(1, round(grow_seconds_for(crop, ci_tier, growth_level) * WATER_BONUS_PCT))
|
||||
def water_bonus_seconds(
|
||||
crop: Crop, ci_tier: int, growth_level: int = 0, legacy_speed_level: int = 0
|
||||
) -> int:
|
||||
return max(
|
||||
1,
|
||||
round(
|
||||
grow_seconds_for(crop, ci_tier, growth_level, legacy_speed_level)
|
||||
* WATER_BONUS_PCT
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def plot_cost(current_plot_count: int) -> int:
|
||||
@@ -168,16 +190,20 @@ def crop_payload(
|
||||
discount_level: int = 0,
|
||||
yield_level: int = 0,
|
||||
prestige: int = 0,
|
||||
legacy_mult_level: int = 0,
|
||||
legacy_speed_level: int = 0,
|
||||
) -> dict:
|
||||
return {
|
||||
"key": crop.key,
|
||||
"name": crop.name,
|
||||
"icon": crop.icon,
|
||||
"cost": effective_plant_cost(crop, discount_level),
|
||||
"reward_coins": effective_reward_coins(crop, yield_level, prestige),
|
||||
"reward_coins": effective_reward_coins(
|
||||
crop, yield_level, prestige, legacy_mult_level
|
||||
),
|
||||
"reward_xp": crop.reward_xp,
|
||||
"min_level": crop.min_level,
|
||||
"grow_seconds": grow_seconds_for(crop, ci_tier, growth_level),
|
||||
"grow_seconds": grow_seconds_for(crop, ci_tier, growth_level, legacy_speed_level),
|
||||
"locked": crop.min_level > level,
|
||||
}
|
||||
|
||||
@@ -206,13 +232,118 @@ PERK_BY_KEY = {perk.key: perk for perk in PERKS}
|
||||
PRESTIGE_MIN_LEVEL = 10
|
||||
PRESTIGE_BONUS = 0.25
|
||||
|
||||
STAR_BASE = 1
|
||||
LEGACY_SPEED_STEP = 0.05
|
||||
LEGACY_MULT_STEP = 0.10
|
||||
LEGACY_DEFENSE_GRACE = 30
|
||||
LEGACY_DEFENSE_FRACTION = 0.05
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacyUpgrade:
|
||||
key: str
|
||||
name: str
|
||||
icon: str
|
||||
description: str
|
||||
max_level: int
|
||||
base_cost: int
|
||||
cost_growth: float
|
||||
|
||||
|
||||
LEGACY_UPGRADES: tuple[LegacyUpgrade, ...] = (
|
||||
LegacyUpgrade(
|
||||
"autoharvest",
|
||||
"CI Bot",
|
||||
"🤖",
|
||||
"Auto-collect ready builds when you open your farm",
|
||||
1,
|
||||
3,
|
||||
1.0,
|
||||
),
|
||||
LegacyUpgrade(
|
||||
"multiplier",
|
||||
"Tech Debt Payoff",
|
||||
"💎",
|
||||
"+10% coins per level, stacks with prestige",
|
||||
10,
|
||||
1,
|
||||
1.6,
|
||||
),
|
||||
LegacyUpgrade(
|
||||
"speed", "Bare-Metal", "🏎️", "+5% base build speed per level", 8, 1, 1.7
|
||||
),
|
||||
LegacyUpgrade(
|
||||
"plots", "Monorepo", "🗂️", "+1 starting plot after refactor per level", 4, 3, 2.0
|
||||
),
|
||||
LegacyUpgrade(
|
||||
"defense",
|
||||
"Branch Protection",
|
||||
"🛡️",
|
||||
"+30s steal grace and -5% steal loss per level",
|
||||
5,
|
||||
2,
|
||||
1.8,
|
||||
),
|
||||
)
|
||||
|
||||
LEGACY_BY_KEY = {up.key: up for up in LEGACY_UPGRADES}
|
||||
|
||||
|
||||
def legacy_for(key: str) -> LegacyUpgrade | None:
|
||||
return LEGACY_BY_KEY.get(key)
|
||||
|
||||
|
||||
def legacy_cost(up: LegacyUpgrade, level: int) -> int:
|
||||
return round(up.base_cost * (up.cost_growth ** level))
|
||||
|
||||
|
||||
def legacy_multiplier(level: int) -> float:
|
||||
return 1 + LEGACY_MULT_STEP * max(0, level)
|
||||
|
||||
|
||||
def stars_for_refactor(level: int, prestige: int) -> int:
|
||||
return STAR_BASE + level // 5 + max(0, prestige)
|
||||
|
||||
|
||||
def effective_steal_grace(defense_level: int = 0) -> int:
|
||||
return STEAL_GRACE_SECONDS + LEGACY_DEFENSE_GRACE * max(0, defense_level)
|
||||
|
||||
|
||||
def effective_steal_fraction(defense_level: int = 0) -> float:
|
||||
return max(0.1, STEAL_FRACTION - LEGACY_DEFENSE_FRACTION * max(0, defense_level))
|
||||
|
||||
|
||||
def prestige_base_plots(legacy_plots_level: int = 0) -> int:
|
||||
return STARTING_PLOTS + max(0, legacy_plots_level)
|
||||
|
||||
|
||||
def legacy_value_text(up: LegacyUpgrade, level: int) -> str:
|
||||
if up.key == "autoharvest":
|
||||
return "Active" if level > 0 else "Inactive"
|
||||
if up.key == "multiplier":
|
||||
return f"+{round(LEGACY_MULT_STEP * level * 100)}% coins"
|
||||
if up.key == "speed":
|
||||
return f"+{round(LEGACY_SPEED_STEP * level * 100)}% build speed"
|
||||
if up.key == "plots":
|
||||
return f"+{level} starting plots"
|
||||
grace = LEGACY_DEFENSE_GRACE * level
|
||||
loss = round(LEGACY_DEFENSE_FRACTION * level * 100)
|
||||
return f"+{grace}s grace, -{loss}% steal loss"
|
||||
|
||||
|
||||
def is_golden(plot_uid: str, planted_at: str) -> bool:
|
||||
if not plot_uid or not planted_at:
|
||||
return False
|
||||
digest = hashlib.sha256(f"{plot_uid}:{planted_at}".encode()).hexdigest()
|
||||
return (int(digest, 16) % 1000) < round(GOLDEN_CHANCE * 1000)
|
||||
|
||||
|
||||
DAILY_BASE = 20
|
||||
DAILY_STREAK_STEP = 12
|
||||
DAILY_STREAK_CAP = 7
|
||||
|
||||
FERTILIZE_FRACTION = 0.5
|
||||
FERTILIZE_COIN_PER_SECOND = 0.3
|
||||
FERTILIZE_MIN_COST = 5
|
||||
FERTILIZE_TAX = 1.05
|
||||
|
||||
|
||||
def perk_for(key: str) -> Perk | None:
|
||||
@@ -242,8 +373,14 @@ def effective_plant_cost(crop: Crop, discount_level: int = 0) -> int:
|
||||
return max(1, round(crop.cost * factor))
|
||||
|
||||
|
||||
def effective_reward_coins(crop: Crop, yield_level: int = 0, prestige: int = 0) -> int:
|
||||
factor = (1 + PERK_BY_KEY["yield"].step * yield_level) * prestige_multiplier(prestige)
|
||||
def effective_reward_coins(
|
||||
crop: Crop, yield_level: int = 0, prestige: int = 0, legacy_mult_level: int = 0
|
||||
) -> int:
|
||||
factor = (
|
||||
(1 + PERK_BY_KEY["yield"].step * yield_level)
|
||||
* prestige_multiplier(prestige)
|
||||
* legacy_multiplier(legacy_mult_level)
|
||||
)
|
||||
return round(crop.reward_coins * factor)
|
||||
|
||||
|
||||
@@ -251,8 +388,21 @@ 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 steal_reward_coins(
|
||||
crop: Crop,
|
||||
yield_level: int = 0,
|
||||
prestige: int = 0,
|
||||
legacy_mult_level: int = 0,
|
||||
defense_level: int = 0,
|
||||
) -> int:
|
||||
fraction = effective_steal_fraction(defense_level)
|
||||
return max(
|
||||
1,
|
||||
round(
|
||||
effective_reward_coins(crop, yield_level, prestige, legacy_mult_level)
|
||||
* fraction
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def daily_reward(streak: int) -> int:
|
||||
@@ -260,8 +410,17 @@ def daily_reward(streak: int) -> int:
|
||||
return DAILY_BASE + DAILY_STREAK_STEP * (effective - 1)
|
||||
|
||||
|
||||
def fertilize_cost(remaining_seconds: int) -> int:
|
||||
return max(FERTILIZE_MIN_COST, round(remaining_seconds * FERTILIZE_COIN_PER_SECOND))
|
||||
def fertilize_click_cost(
|
||||
effective_reward_coins: int, reduce_seconds: int, full_grow_seconds: int
|
||||
) -> int:
|
||||
if reduce_seconds < 1 or full_grow_seconds < 1:
|
||||
return 0
|
||||
return max(
|
||||
1,
|
||||
math.ceil(
|
||||
effective_reward_coins * reduce_seconds / full_grow_seconds * FERTILIZE_TAX
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -53,6 +53,27 @@ def _quests():
|
||||
return get_table("game_quests")
|
||||
|
||||
|
||||
def _steals():
|
||||
return get_table("game_steals")
|
||||
|
||||
|
||||
def last_steal_at(thief_uid: str, owner_uid: str) -> datetime | None:
|
||||
latest = None
|
||||
for row in _steals().find(thief_uid=thief_uid, owner_uid=owner_uid):
|
||||
stamp = _parse(row.get("stolen_at", ""))
|
||||
if stamp and (latest is None or stamp > latest):
|
||||
latest = stamp
|
||||
return latest
|
||||
|
||||
|
||||
def steal_cooldown_remaining(thief_uid: str, owner_uid: str, now: datetime) -> int:
|
||||
latest = last_steal_at(thief_uid, owner_uid)
|
||||
if not latest:
|
||||
return 0
|
||||
elapsed = (now - latest).total_seconds()
|
||||
return max(0, int(economy.STEAL_COOLDOWN_SECONDS - elapsed))
|
||||
|
||||
|
||||
def _lvl(farm: dict, key: str) -> int:
|
||||
return int(farm.get(key) or 0)
|
||||
|
||||
@@ -141,6 +162,12 @@ def serialize_plot(
|
||||
now: datetime,
|
||||
yield_level: int = 0,
|
||||
prestige: int = 0,
|
||||
growth_level: int = 0,
|
||||
ci_tier: int = 1,
|
||||
legacy_mult_level: int = 0,
|
||||
legacy_speed_level: int = 0,
|
||||
defense_level: int = 0,
|
||||
steal_locked_until: int = 0,
|
||||
) -> dict:
|
||||
state = _plot_state(plot, now)
|
||||
crop = economy.crop_for(plot.get("crop_key", ""))
|
||||
@@ -150,6 +177,7 @@ def serialize_plot(
|
||||
remaining = max(0, int((ready_at - now).total_seconds()))
|
||||
watered = _watered_by(plot)
|
||||
is_owner = viewer_uid == owner_uid
|
||||
golden = economy.is_golden(plot.get("uid", ""), plot.get("planted_at", ""))
|
||||
can_water = (
|
||||
state == "growing"
|
||||
and not is_owner
|
||||
@@ -157,17 +185,38 @@ def serialize_plot(
|
||||
and viewer_uid not in watered
|
||||
and len(watered) < economy.MAX_WATERS_PER_PLOT
|
||||
)
|
||||
can_steal = (
|
||||
grace = economy.effective_steal_grace(defense_level)
|
||||
protected = bool(ready_at) and now < ready_at + timedelta(seconds=grace)
|
||||
eligible = (
|
||||
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)
|
||||
)
|
||||
can_steal = eligible and not protected and steal_locked_until <= 0
|
||||
steal_reason = ""
|
||||
if eligible and protected:
|
||||
steal_reason = "protected"
|
||||
elif eligible and steal_locked_until > 0:
|
||||
steal_reason = "cooldown"
|
||||
steal_coins = (
|
||||
economy.steal_reward_coins(crop, yield_level, prestige) if can_steal else 0
|
||||
economy.steal_reward_coins(
|
||||
crop, yield_level, prestige, legacy_mult_level, defense_level
|
||||
)
|
||||
if can_steal
|
||||
else 0
|
||||
)
|
||||
fertilize_cost = 0
|
||||
if state == "growing" and is_owner and crop:
|
||||
full_grow = economy.grow_seconds_for(
|
||||
crop, ci_tier, growth_level, legacy_speed_level
|
||||
)
|
||||
reduce_by = int(remaining * economy.FERTILIZE_FRACTION)
|
||||
eff_reward = economy.effective_reward_coins(
|
||||
crop, yield_level, prestige, legacy_mult_level
|
||||
)
|
||||
fertilize_cost = economy.fertilize_click_cost(eff_reward, reduce_by, full_grow)
|
||||
return {
|
||||
"slot": plot.get("slot_index", 0),
|
||||
"state": state,
|
||||
@@ -183,9 +232,10 @@ def serialize_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
|
||||
),
|
||||
"steal_cooldown_seconds": steal_locked_until if steal_reason == "cooldown" else 0,
|
||||
"steal_reason": steal_reason,
|
||||
"is_golden": golden and (is_owner or can_steal),
|
||||
"fertilize_cost": fertilize_cost,
|
||||
}
|
||||
|
||||
|
||||
@@ -195,10 +245,22 @@ def serialize_farm(
|
||||
now = now or _now()
|
||||
viewer_uid = viewer["uid"] if viewer else ""
|
||||
owner_uid = owner["uid"]
|
||||
is_owner = viewer_uid == owner_uid
|
||||
if is_owner and _lvl(farm, "legacy_autoharvest") > 0:
|
||||
farm = _auto_harvest(farm, owner_uid, now)
|
||||
prestige = _lvl(farm, "prestige")
|
||||
growth_level = _lvl(farm, "perk_growth")
|
||||
discount_level = _lvl(farm, "perk_discount")
|
||||
yield_level = _lvl(farm, "perk_yield")
|
||||
legacy_mult_level = _lvl(farm, "legacy_multiplier")
|
||||
legacy_speed_level = _lvl(farm, "legacy_speed")
|
||||
defense_level = _lvl(farm, "legacy_defense")
|
||||
ci_tier = int(farm.get("ci_tier", 1))
|
||||
steal_locked_until = (
|
||||
steal_cooldown_remaining(viewer_uid, owner_uid, now)
|
||||
if viewer_uid and not is_owner
|
||||
else 0
|
||||
)
|
||||
plots = get_plots(farm["uid"])
|
||||
serialized_plots = [
|
||||
serialize_plot(
|
||||
@@ -208,15 +270,19 @@ def serialize_farm(
|
||||
now=now,
|
||||
yield_level=yield_level,
|
||||
prestige=prestige,
|
||||
growth_level=growth_level,
|
||||
ci_tier=ci_tier,
|
||||
legacy_mult_level=legacy_mult_level,
|
||||
legacy_speed_level=legacy_speed_level,
|
||||
defense_level=defense_level,
|
||||
steal_locked_until=steal_locked_until,
|
||||
)
|
||||
for plot in plots
|
||||
]
|
||||
progress = economy.level_progress(int(farm.get("xp", 0)))
|
||||
level = progress["level"]
|
||||
ci_tier = int(farm.get("ci_tier", 1))
|
||||
next_tier = economy.next_ci_tier(ci_tier)
|
||||
ci_entry = economy.CI_BY_TIER.get(ci_tier)
|
||||
is_owner = viewer_uid == owner_uid
|
||||
streak = _lvl(farm, "streak")
|
||||
daily_available = is_owner and _daily_available(farm, now)
|
||||
return {
|
||||
@@ -246,7 +312,15 @@ def serialize_farm(
|
||||
"plots": serialized_plots,
|
||||
"crops": [
|
||||
economy.crop_payload(
|
||||
crop, ci_tier, level, growth_level, discount_level, yield_level, prestige
|
||||
crop,
|
||||
ci_tier,
|
||||
level,
|
||||
growth_level,
|
||||
discount_level,
|
||||
yield_level,
|
||||
prestige,
|
||||
legacy_mult_level,
|
||||
legacy_speed_level,
|
||||
)
|
||||
for crop in economy.CROPS
|
||||
],
|
||||
@@ -259,6 +333,9 @@ def serialize_farm(
|
||||
"daily_reward": economy.daily_reward(streak + 1 if daily_available else streak),
|
||||
"perks": _serialize_perks(farm) if is_owner else [],
|
||||
"quests": _serialize_quests(owner_uid, now) if is_owner else [],
|
||||
"stars": _lvl(farm, "stars"),
|
||||
"legacy": _serialize_legacy(farm) if is_owner else [],
|
||||
"steal_cooldown_seconds": steal_locked_until,
|
||||
}
|
||||
|
||||
|
||||
@@ -286,7 +363,9 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
|
||||
raise GameError("Not enough coins to plant that.")
|
||||
now = _now()
|
||||
ci_tier = int(farm.get("ci_tier", 1))
|
||||
grow = economy.grow_seconds_for(crop, ci_tier, _lvl(farm, "perk_growth"))
|
||||
grow = economy.grow_seconds_for(
|
||||
crop, ci_tier, _lvl(farm, "perk_growth"), _lvl(farm, "legacy_speed")
|
||||
)
|
||||
ready_at = now + timedelta(seconds=grow)
|
||||
_plots().update(
|
||||
{
|
||||
@@ -327,7 +406,12 @@ def harvest(user: dict, slot: int) -> dict:
|
||||
["uid"],
|
||||
)
|
||||
prestige = _lvl(farm, "prestige")
|
||||
coins_gain = economy.effective_reward_coins(crop, _lvl(farm, "perk_yield"), prestige)
|
||||
golden = economy.is_golden(plot.get("uid", ""), plot.get("planted_at", ""))
|
||||
coins_gain = economy.effective_reward_coins(
|
||||
crop, _lvl(farm, "perk_yield"), prestige, _lvl(farm, "legacy_multiplier")
|
||||
)
|
||||
if golden:
|
||||
coins_gain *= economy.GOLDEN_MULTIPLIER
|
||||
xp_gain = economy.effective_reward_xp(crop, _lvl(farm, "perk_xp"))
|
||||
new_xp = int(farm.get("xp", 0)) + xp_gain
|
||||
_update_farm(
|
||||
@@ -346,6 +430,7 @@ def harvest(user: dict, slot: int) -> dict:
|
||||
"crop": crop.key,
|
||||
"coins": coins_gain,
|
||||
"xp": xp_gain,
|
||||
"golden": golden,
|
||||
}
|
||||
|
||||
|
||||
@@ -397,7 +482,7 @@ def water(visitor: dict, owner: dict, slot: int) -> dict:
|
||||
crop = economy.crop_for(plot.get("crop_key", ""))
|
||||
ready_at = _parse(plot.get("ready_at", "")) or now
|
||||
bonus = economy.water_bonus_seconds(
|
||||
crop, int(farm.get("ci_tier", 1)), _lvl(farm, "perk_growth")
|
||||
crop, int(farm.get("ci_tier", 1)), _lvl(farm, "perk_growth"), _lvl(farm, "legacy_speed")
|
||||
)
|
||||
new_ready = max(now, ready_at - timedelta(seconds=bonus))
|
||||
watered.append(visitor["uid"])
|
||||
@@ -442,9 +527,18 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
|
||||
crop = economy.crop_for(plot.get("crop_key", ""))
|
||||
if not crop:
|
||||
raise GameError("Unknown crop type.")
|
||||
defense_level = _lvl(farm, "legacy_defense")
|
||||
ready_at = _parse(plot.get("ready_at", "")) or now
|
||||
if now < ready_at + timedelta(seconds=economy.STEAL_GRACE_SECONDS):
|
||||
grace = economy.effective_steal_grace(defense_level)
|
||||
if now < ready_at + timedelta(seconds=grace):
|
||||
raise GameError("That harvest is still protected.")
|
||||
cooldown = steal_cooldown_remaining(thief["uid"], owner["uid"], now)
|
||||
if cooldown > 0:
|
||||
minutes = max(1, (cooldown + 59) // 60)
|
||||
raise GameError(
|
||||
f"You can only raid {owner.get('username', 'this farmer')} "
|
||||
f"once an hour. Try again in {minutes} min."
|
||||
)
|
||||
_plots().update(
|
||||
{
|
||||
"uid": plot["uid"],
|
||||
@@ -457,13 +551,29 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
|
||||
["uid"],
|
||||
)
|
||||
coins_gain = economy.steal_reward_coins(
|
||||
crop, _lvl(farm, "perk_yield"), _lvl(farm, "prestige")
|
||||
crop,
|
||||
_lvl(farm, "perk_yield"),
|
||||
_lvl(farm, "prestige"),
|
||||
_lvl(farm, "legacy_multiplier"),
|
||||
defense_level,
|
||||
)
|
||||
thief_farm = ensure_farm(thief["uid"])
|
||||
_update_farm(
|
||||
thief_farm["uid"],
|
||||
{"coins": int(thief_farm.get("coins", 0)) + coins_gain},
|
||||
)
|
||||
_steals().insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"thief_uid": thief["uid"],
|
||||
"owner_uid": owner["uid"],
|
||||
"slot_index": slot,
|
||||
"crop_key": crop.key,
|
||||
"coins": coins_gain,
|
||||
"stolen_at": _iso(now),
|
||||
"created_at": _iso(now),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"slot": slot,
|
||||
"crop": crop.key,
|
||||
@@ -529,6 +639,76 @@ def _serialize_perks(farm: dict) -> list[dict]:
|
||||
return perks
|
||||
|
||||
|
||||
def _serialize_legacy(farm: dict) -> list[dict]:
|
||||
upgrades = []
|
||||
for up in economy.LEGACY_UPGRADES:
|
||||
level = _lvl(farm, f"legacy_{up.key}")
|
||||
maxed = level >= up.max_level
|
||||
upgrades.append(
|
||||
{
|
||||
"key": up.key,
|
||||
"name": up.name,
|
||||
"icon": up.icon,
|
||||
"description": up.description,
|
||||
"level": level,
|
||||
"max_level": up.max_level,
|
||||
"cost": 0 if maxed else economy.legacy_cost(up, level),
|
||||
"maxed": maxed,
|
||||
"effect": economy.legacy_value_text(up, level),
|
||||
}
|
||||
)
|
||||
return upgrades
|
||||
|
||||
|
||||
def _auto_harvest(farm: dict, owner_uid: str, now: datetime) -> dict:
|
||||
yield_level = _lvl(farm, "perk_yield")
|
||||
xp_level = _lvl(farm, "perk_xp")
|
||||
prestige = _lvl(farm, "prestige")
|
||||
mult_level = _lvl(farm, "legacy_multiplier")
|
||||
coins_gain = 0
|
||||
xp_gain = 0
|
||||
harvested = 0
|
||||
for plot in get_plots(farm["uid"]):
|
||||
if _plot_state(plot, now) != "ready":
|
||||
continue
|
||||
crop = economy.crop_for(plot.get("crop_key", ""))
|
||||
if not crop:
|
||||
continue
|
||||
golden = economy.is_golden(plot.get("uid", ""), plot.get("planted_at", ""))
|
||||
_plots().update(
|
||||
{
|
||||
"uid": plot["uid"],
|
||||
"crop_key": "",
|
||||
"planted_at": "",
|
||||
"ready_at": "",
|
||||
"watered_by": "[]",
|
||||
"updated_at": _iso(now),
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
coins = economy.effective_reward_coins(crop, yield_level, prestige, mult_level)
|
||||
if golden:
|
||||
coins *= economy.GOLDEN_MULTIPLIER
|
||||
coins_gain += coins
|
||||
xp_gain += economy.effective_reward_xp(crop, xp_level)
|
||||
harvested += 1
|
||||
if not harvested:
|
||||
return farm
|
||||
new_xp = int(farm.get("xp", 0)) + xp_gain
|
||||
_update_farm(
|
||||
farm["uid"],
|
||||
{
|
||||
"coins": int(farm.get("coins", 0)) + coins_gain,
|
||||
"xp": new_xp,
|
||||
"level": economy.level_for_xp(new_xp),
|
||||
"total_harvests": int(farm.get("total_harvests", 0)) + harvested,
|
||||
},
|
||||
)
|
||||
advance_quests(owner_uid, "harvest", harvested)
|
||||
advance_quests(owner_uid, "earn", coins_gain)
|
||||
return get_farm(owner_uid) or farm
|
||||
|
||||
|
||||
def ensure_quests(farm: dict, day: str) -> None:
|
||||
if _quests().find_one(farm_uid=farm["uid"], day=day):
|
||||
return
|
||||
@@ -670,6 +850,24 @@ def upgrade_perk(user: dict, perk_key: str) -> dict:
|
||||
return {"perk": perk.key, "level": level + 1, "spent": cost}
|
||||
|
||||
|
||||
def upgrade_legacy(user: dict, key: str) -> dict:
|
||||
upgrade = economy.legacy_for(key)
|
||||
if not upgrade:
|
||||
raise GameError("Unknown legacy upgrade.")
|
||||
farm = ensure_farm(user["uid"])
|
||||
column = f"legacy_{upgrade.key}"
|
||||
level = _lvl(farm, column)
|
||||
if level >= upgrade.max_level:
|
||||
raise GameError("That legacy upgrade is maxed out.")
|
||||
cost = economy.legacy_cost(upgrade, level)
|
||||
if _lvl(farm, "stars") < cost:
|
||||
raise GameError("Not enough stars for that legacy upgrade.")
|
||||
_update_farm(
|
||||
farm["uid"], {"stars": _lvl(farm, "stars") - cost, column: level + 1}
|
||||
)
|
||||
return {"key": upgrade.key, "level": level + 1, "spent": cost}
|
||||
|
||||
|
||||
def prestige(user: dict) -> dict:
|
||||
farm = ensure_farm(user["uid"])
|
||||
level = economy.level_for_xp(int(farm.get("xp", 0)))
|
||||
@@ -678,11 +876,15 @@ def prestige(user: dict) -> dict:
|
||||
f"Reach level {economy.PRESTIGE_MIN_LEVEL} to refactor (prestige)."
|
||||
)
|
||||
new_prestige = _lvl(farm, "prestige") + 1
|
||||
stars_award = economy.stars_for_refactor(level, _lvl(farm, "prestige"))
|
||||
base_plots = economy.prestige_base_plots(_lvl(farm, "legacy_plots"))
|
||||
now = _iso(_now())
|
||||
kept_slots = set()
|
||||
for plot in get_plots(farm["uid"]):
|
||||
if int(plot.get("slot_index", 0)) >= economy.STARTING_PLOTS:
|
||||
if int(plot.get("slot_index", 0)) >= base_plots:
|
||||
_plots().delete(uid=plot["uid"])
|
||||
else:
|
||||
kept_slots.add(int(plot.get("slot_index", 0)))
|
||||
_plots().update(
|
||||
{
|
||||
"uid": plot["uid"],
|
||||
@@ -694,18 +896,22 @@ def prestige(user: dict) -> dict:
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
for slot_index in range(base_plots):
|
||||
if slot_index not in kept_slots:
|
||||
_create_plot(farm["uid"], user["uid"], slot_index, now)
|
||||
reset = {
|
||||
"coins": economy.STARTING_COINS,
|
||||
"xp": 0,
|
||||
"level": 1,
|
||||
"ci_tier": 1,
|
||||
"plot_count": economy.STARTING_PLOTS,
|
||||
"plot_count": base_plots,
|
||||
"prestige": new_prestige,
|
||||
"stars": _lvl(farm, "stars") + stars_award,
|
||||
}
|
||||
for perk in economy.PERKS:
|
||||
reset[PERK_COLUMN[perk.key]] = 0
|
||||
_update_farm(farm["uid"], reset)
|
||||
return {"prestige": new_prestige}
|
||||
return {"prestige": new_prestige, "stars_awarded": stars_award}
|
||||
|
||||
|
||||
def fertilize(user: dict, slot: int) -> dict:
|
||||
@@ -716,12 +922,23 @@ def fertilize(user: dict, slot: int) -> dict:
|
||||
now = _now()
|
||||
if _plot_state(plot, now) != "growing":
|
||||
raise GameError("That build is not running.")
|
||||
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
|
||||
remaining = max(1, int((ready_at - now).total_seconds()))
|
||||
cost = economy.fertilize_cost(remaining)
|
||||
reduce_by = int(remaining * economy.FERTILIZE_FRACTION)
|
||||
if reduce_by < 1:
|
||||
raise GameError("That build is almost ready already.")
|
||||
full_grow = economy.grow_seconds_for(
|
||||
crop, int(farm.get("ci_tier", 1)), _lvl(farm, "perk_growth"), _lvl(farm, "legacy_speed")
|
||||
)
|
||||
eff_reward = economy.effective_reward_coins(
|
||||
crop, _lvl(farm, "perk_yield"), _lvl(farm, "prestige"), _lvl(farm, "legacy_multiplier")
|
||||
)
|
||||
cost = economy.fertilize_click_cost(eff_reward, reduce_by, full_grow)
|
||||
if int(farm.get("coins", 0)) < cost:
|
||||
raise GameError("Not enough coins to fertilize.")
|
||||
reduce_by = int(remaining * economy.FERTILIZE_FRACTION)
|
||||
new_ready = max(now, ready_at - timedelta(seconds=reduce_by))
|
||||
_plots().update(
|
||||
{"uid": plot["uid"], "ready_at": _iso(new_ready), "updated_at": _iso(now)},
|
||||
|
||||
@@ -501,3 +501,27 @@
|
||||
.game-perks {
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.game-legacy {
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.game-legacy-note {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 var(--space-md);
|
||||
}
|
||||
|
||||
.legacy-card {
|
||||
border-color: #f5c518;
|
||||
}
|
||||
|
||||
.game-plot-golden {
|
||||
border-color: #f5c518;
|
||||
box-shadow: 0 0 0 1px #f5c518 inset, 0 0 12px rgba(245, 197, 24, 0.4);
|
||||
}
|
||||
|
||||
.plot-steal-locked {
|
||||
color: var(--warning);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.avatar-regen-btn {
|
||||
display: block;
|
||||
margin: 0.5rem auto 0;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ReactionBar } from "./ReactionBar.js";
|
||||
import { BookmarkManager } from "./BookmarkManager.js";
|
||||
import { PollManager } from "./PollManager.js";
|
||||
import { ApiKeyManager } from "./ApiKeyManager.js";
|
||||
import { AvatarRegenerator } from "./AvatarRegenerator.js";
|
||||
import { CustomizationToggle } from "./CustomizationToggle.js";
|
||||
import { NotificationPrefs } from "./NotificationPrefs.js";
|
||||
import { AiCorrection } from "./AiCorrection.js";
|
||||
@@ -67,6 +68,7 @@ class Application {
|
||||
this.bookmarks = new BookmarkManager();
|
||||
this.polls = new PollManager();
|
||||
this.apiKey = new ApiKeyManager();
|
||||
this.avatarRegenerator = new AvatarRegenerator();
|
||||
this.customizationToggle = new CustomizationToggle();
|
||||
this.notificationPrefs = new NotificationPrefs(this.pubsub);
|
||||
this.aiCorrection = new AiCorrection();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
import { Toast } from "./Toast.js";
|
||||
|
||||
let confirmedThisSession = false;
|
||||
|
||||
class AvatarRegenerator {
|
||||
constructor() {
|
||||
this.btn = document.querySelector("[data-regenerate-avatar]");
|
||||
if (!this.btn) return;
|
||||
this.preview = document.getElementById("profile-avatar-preview");
|
||||
this.btn.addEventListener("click", () => this.regenerate());
|
||||
}
|
||||
|
||||
async regenerate() {
|
||||
if (!confirmedThisSession) {
|
||||
const ok = await window.app.dialog.confirm({
|
||||
title: "Regenerate avatar",
|
||||
message: "A new random avatar will be generated. Your current avatar is gone for good and can never be brought back.",
|
||||
confirmLabel: "Regenerate",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
confirmedThisSession = true;
|
||||
}
|
||||
const username = this.btn.dataset.username;
|
||||
this.btn.disabled = true;
|
||||
try {
|
||||
const result = await Http.postJson(`/profile/${username}/regenerate-avatar`, {});
|
||||
const url = result && result.data ? result.data.avatar_url : "";
|
||||
if (!url) {
|
||||
Toast.flash(this.btn, "Error", 2000, "Regenerate avatar");
|
||||
return;
|
||||
}
|
||||
if (this.preview) this.preview.src = url;
|
||||
Toast.flash(this.btn, "Regenerated", 2000, "Regenerate avatar");
|
||||
} catch {
|
||||
Toast.flash(this.btn, "Error", 2000, "Regenerate avatar");
|
||||
} finally {
|
||||
this.btn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.AvatarRegenerator = AvatarRegenerator;
|
||||
export { AvatarRegenerator };
|
||||
@@ -183,8 +183,10 @@ export class CommentManager {
|
||||
actions.insertAdjacentElement("afterend", form);
|
||||
|
||||
this.enhanceForm(form);
|
||||
const textarea = form.querySelector("textarea");
|
||||
if (textarea) textarea.focus();
|
||||
setTimeout(() => {
|
||||
const textarea = form.querySelector("textarea");
|
||||
if (textarea) textarea.focus();
|
||||
}, 20);
|
||||
}
|
||||
|
||||
enhanceForm(form) {
|
||||
@@ -192,7 +194,6 @@ export class CommentManager {
|
||||
if (enhancer) {
|
||||
enhancer.initEmojiPickers();
|
||||
enhancer.initMentionInputs();
|
||||
enhancer.initAttachmentManagers();
|
||||
}
|
||||
const textarea = form.querySelector("textarea");
|
||||
if (textarea) {
|
||||
|
||||
@@ -69,6 +69,7 @@ export class GameFarm {
|
||||
if (this.mode === "own") {
|
||||
this._setHost("[data-shop-host]", this._shopHtml(farm));
|
||||
this._setHost("[data-perk-host]", this._perksHtml(farm));
|
||||
this._setHost("[data-legacy-host]", this._legacyHtml(farm));
|
||||
this._setHost("[data-daily-host]", this._dailyHtml(farm));
|
||||
this._setHost("[data-quest-host]", this._questsHtml(farm));
|
||||
}
|
||||
@@ -92,6 +93,7 @@ export class GameFarm {
|
||||
set("[data-hud-harvests]", farm.total_harvests);
|
||||
set("[data-hud-prestige]", farm.prestige);
|
||||
set("[data-hud-prestige-mult]", `+${Math.round(farm.prestige_multiplier * 100 - 100)}% coins`);
|
||||
set("[data-hud-stars]", farm.stars);
|
||||
const fill = this.root.querySelector("[data-hud-xp-fill]");
|
||||
if (fill) {
|
||||
const pct = farm.level_is_max || !farm.level_span ? 100 : Math.floor((100 * farm.level_into) / farm.level_span);
|
||||
@@ -130,6 +132,17 @@ export class GameFarm {
|
||||
.join("");
|
||||
}
|
||||
|
||||
_legacyHtml(farm) {
|
||||
return (farm.legacy || [])
|
||||
.map((up) => {
|
||||
const action = up.maxed
|
||||
? `<span class="perk-maxed">Maxed</span>`
|
||||
: `<form method="post" action="/game/legacy" data-game-action="legacy"><input type="hidden" name="key" value="${up.key}"><button type="submit" class="btn btn-sm">Buy (${up.cost} ★)</button></form>`;
|
||||
return `<div class="perk-card legacy-card"><span class="perk-icon" aria-hidden="true">${up.icon}</span><strong class="perk-name">${up.name}</strong><span class="perk-level">Lv ${up.level}/${up.max_level}</span><span class="perk-effect">${up.effect || up.description}</span>${action}</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
_dailyHtml(farm) {
|
||||
const action = farm.daily_available
|
||||
? `<form method="post" action="/game/daily" data-game-action="daily"><button type="submit" class="btn btn-sm btn-primary">Claim +${farm.daily_reward}c</button></form>`
|
||||
@@ -164,7 +177,8 @@ export class GameFarm {
|
||||
}
|
||||
|
||||
_plotHtml(plot, farm) {
|
||||
const head = `<div class="game-plot game-plot-${plot.state}" data-slot="${plot.slot}" data-state="${plot.state}" data-ready-at="${plot.ready_at}">`;
|
||||
const golden = plot.is_golden ? " game-plot-golden" : "";
|
||||
const head = `<div class="game-plot game-plot-${plot.state}${golden}" data-slot="${plot.slot}" data-state="${plot.state}" data-ready-at="${plot.ready_at}">`;
|
||||
let body = "";
|
||||
if (plot.state === "empty") {
|
||||
if (farm.is_owner) {
|
||||
@@ -188,11 +202,15 @@ export class GameFarm {
|
||||
body += `<form method="post" action="/game/fertilize" data-game-action="fertilize"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm">⚡ Fertilize (${plot.fertilize_cost}c)</button></form>`;
|
||||
}
|
||||
} else {
|
||||
body = `<span class="plot-icon plot-ready-icon" aria-hidden="true">${plot.crop_icon}</span><span class="plot-crop-name">${plot.crop_name}</span>`;
|
||||
const goldenMark = plot.is_golden ? " ✨" : "";
|
||||
body = `<span class="plot-icon plot-ready-icon" aria-hidden="true">${plot.crop_icon}</span><span class="plot-crop-name">${plot.crop_name}${goldenMark}</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>`;
|
||||
const label = plot.is_golden ? "Harvest golden" : "Harvest";
|
||||
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">${label}</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 if (plot.steal_reason === "cooldown") {
|
||||
body += `<span class="plot-ready-label plot-steal-locked">Raid again in ${this._format(plot.steal_cooldown_seconds)}</span>`;
|
||||
} else {
|
||||
body += `<span class="plot-ready-label">Ready</span>`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% if _user %}
|
||||
<a href="/profile/{{ _user['username'] }}" class="user-avatar-link">
|
||||
<img src="{{ avatar_url('multiavatar', _user['username'], _size) }}" class="avatar-img avatar-{{ _size_class }}" alt="{{ _user['username'] }}" loading="lazy">
|
||||
<img src="{{ avatar_url('multiavatar', avatar_seed(_user), _size) }}" class="avatar-img avatar-{{ _size_class }}" alt="{{ _user['username'] }}" loading="lazy">
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="game-grid" data-game-grid>
|
||||
{% for plot in farm.plots %}
|
||||
<div class="game-plot game-plot-{{ plot.state }}" data-slot="{{ plot.slot }}" data-state="{{ plot.state }}" data-ready-at="{{ plot.ready_at }}">
|
||||
<div class="game-plot game-plot-{{ plot.state }}{% if plot.is_golden %} game-plot-golden{% endif %}" data-slot="{{ plot.slot }}" data-state="{{ plot.state }}" data-ready-at="{{ plot.ready_at }}">
|
||||
{% if plot.state == 'empty' %}
|
||||
{% if farm.is_owner %}
|
||||
<form class="plot-plant-form" method="post" action="/game/plant" data-game-action="plant">
|
||||
@@ -34,17 +34,19 @@
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="plot-icon plot-ready-icon" aria-hidden="true">{{ plot.crop_icon }}</span>
|
||||
<span class="plot-crop-name">{{ plot.crop_name }}</span>
|
||||
<span class="plot-crop-name">{{ plot.crop_name }}{% if plot.is_golden %} ✨{% endif %}</span>
|
||||
{% if farm.is_owner %}
|
||||
<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>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Harvest{% if plot.is_golden %} golden{% endif %}</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>
|
||||
{% elif plot.steal_reason == 'cooldown' %}
|
||||
<span class="plot-ready-label plot-steal-locked">Raided recently</span>
|
||||
{% else %}
|
||||
<span class="plot-ready-label">Ready</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{% for up in farm.legacy %}
|
||||
<div class="perk-card legacy-card">
|
||||
<span class="perk-icon" aria-hidden="true">{{ up.icon }}</span>
|
||||
<strong class="perk-name">{{ up.name }}</strong>
|
||||
<span class="perk-level">Lv {{ up.level }}/{{ up.max_level }}</span>
|
||||
<span class="perk-effect">{{ up.effect or up.description }}</span>
|
||||
{% if up.maxed %}
|
||||
<span class="perk-maxed">Maxed</span>
|
||||
{% else %}
|
||||
<form method="post" action="/game/legacy" data-game-action="legacy">
|
||||
<input type="hidden" name="key" value="{{ up.key }}">
|
||||
<button type="submit" class="btn btn-sm">Buy ({{ up.cost }} ★)</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -12,11 +12,11 @@
|
||||
<div class="shop-row">
|
||||
<div class="shop-info">
|
||||
<strong>Refactor (prestige {{ farm.prestige }})</strong>
|
||||
<span>{% if farm.prestige_available %}Reset your farm for a permanent +25% coin bonus.{% else %}Reach level {{ farm.prestige_min_level }} to refactor.{% endif %}</span>
|
||||
<span>{% if farm.prestige_available %}Reset your farm for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades.{% else %}Reach level {{ farm.prestige_min_level }} to refactor.{% endif %}</span>
|
||||
</div>
|
||||
{% if farm.prestige_available %}
|
||||
<form method="post" action="/game/prestige" data-game-action="prestige">
|
||||
<button type="submit" class="btn btn-sm btn-primary" data-confirm="Refactor resets coins, level, CI, extra plots, and perks for a permanent +25% coin bonus. Continue?">Refactor</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" data-confirm="Refactor resets coins, level, CI, extra plots, and perks for a permanent +25% coin bonus. You keep your Stars and Legacy upgrades and earn more Stars. Continue?">Refactor</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<div class="post-votes" role="group" aria-label="Post votes">
|
||||
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button>
|
||||
</form>
|
||||
<span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span>
|
||||
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if _my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>−</button>
|
||||
</form>
|
||||
<span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span>
|
||||
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
{% endif %}
|
||||
<div class="topnav-user-dropdown">
|
||||
<button type="button" class="topnav-user" aria-label="User menu" aria-expanded="false" aria-controls="user-menu">
|
||||
<img src="{{ avatar_url('multiavatar', user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">
|
||||
<img src="{{ avatar_url('multiavatar', avatar_seed(user), 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">
|
||||
<div class="topnav-user-info">
|
||||
<span class="topnav-user-name">{{ user['username'] }}</span>
|
||||
<span class="topnav-user-level">Level {{ user.get('level', 1) }}</span>
|
||||
@@ -160,7 +160,7 @@
|
||||
{% endif %}
|
||||
<div class="topnav-mobile-divider"></div>
|
||||
<div class="topnav-mobile-user">
|
||||
<img src="{{ avatar_url('multiavatar', user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">
|
||||
<img src="{{ avatar_url('multiavatar', avatar_seed(user), 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">
|
||||
<div class="topnav-mobile-user-info">
|
||||
<span>{{ user['username'] }}</span>
|
||||
<span class="topnav-mobile-user-level">Level {{ user.get('level', 1) }}</span>
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
<span class="hud-value" data-hud-prestige>{{ farm.prestige }}</span>
|
||||
<span class="hud-sub" data-hud-prestige-mult>+{{ (farm.prestige_multiplier * 100 - 100)|round|int }}% coins</span>
|
||||
</div>
|
||||
<div class="hud-stat">
|
||||
<span class="hud-label">Stars</span>
|
||||
<span class="hud-value" data-hud-stars>{{ farm.stars }}</span>
|
||||
<span class="hud-sub">spend on Legacy</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="game-layout">
|
||||
@@ -50,6 +55,12 @@
|
||||
<h2 class="game-section-title">Perks</h2>
|
||||
<div class="perk-grid" data-perk-host>{% include "_game_perks.html" %}</div>
|
||||
</section>
|
||||
|
||||
<section class="game-legacy">
|
||||
<h2 class="game-section-title">Legacy (Stars)</h2>
|
||||
<p class="game-legacy-note">Earn Stars every Refactor. Legacy upgrades are permanent and survive every refactor.</p>
|
||||
<div class="perk-grid" data-legacy-host>{% include "_game_legacy.html" %}</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="game-side">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
<div class="issue-meta">
|
||||
{% if issue.is_local_author %}
|
||||
{% set _user = {'username': issue.author_username} %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% set _user = {'username': issue.author_username, 'avatar_seed': issue.author_avatar_seed} %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% endif %}
|
||||
<span class="issue-author">{{ issue.author_username }}</span>
|
||||
<span class="issue-dot">·</span>
|
||||
@@ -60,7 +60,7 @@
|
||||
<div class="issue-comment">
|
||||
<div class="issue-comment-head">
|
||||
{% if comment.is_local_author %}
|
||||
{% set _user = {'username': comment.author_username} %}{% set _size = 20 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% set _user = {'username': comment.author_username, 'avatar_seed': comment.author_avatar_seed} %}{% set _size = 20 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% else %}
|
||||
<span class="issue-comment-badge"><span class="sr-only">Developer reply from </span>dev</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
<div class="issue-meta">
|
||||
{% if issue.is_local_author %}
|
||||
{% set _user = {'username': issue.author_username} %}{% set _size = 20 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% set _user = {'username': issue.author_username, 'avatar_seed': issue.author_avatar_seed} %}{% set _size = 20 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% endif %}
|
||||
<span class="issue-author">{{ issue.author_username }}</span>
|
||||
<span class="issue-dot">·</span>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="messages-conversations" role="list" aria-label="Conversation list">
|
||||
{% for conv in conversations %}
|
||||
<a href="/messages?with_uid={{ conv.other_user['uid'] }}" class="conversation-item {% if current_conversation == conv.other_user['uid'] %}active{% endif %}" data-conv-uid="{{ conv.other_user['uid'] }}" role="listitem"{% if current_conversation == conv.other_user['uid'] %} aria-current="true"{% endif %}>
|
||||
<img src="{{ avatar_url('multiavatar', conv.other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ conv.other_user['username'] }}" loading="lazy">
|
||||
<img src="{{ avatar_url('multiavatar', avatar_seed(conv.other_user), 32) }}" class="avatar-img avatar-sm" alt="{{ conv.other_user['username'] }}" loading="lazy">
|
||||
<div class="conversation-info">
|
||||
<div class="conversation-name">{{ conv.other_user['username'] }}</div>
|
||||
<div class="conversation-preview">{{ content_preview(conv.last_message, 60) }}</div>
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
<aside class="profile-sidebar">
|
||||
<div class="profile-card">
|
||||
<div class="profile-avatar-wrap">
|
||||
<img src="{{ avatar_url('multiavatar', profile_user['username'], 80) }}" class="avatar-img avatar-lg" alt="{{ profile_user['username'] }}" id="profile-avatar-preview" loading="lazy">
|
||||
<img src="{{ avatar_url('multiavatar', avatar_seed(profile_user), 80) }}" class="avatar-img avatar-lg" alt="{{ profile_user['username'] }}" id="profile-avatar-preview" loading="lazy">
|
||||
{% if is_owner or viewer_is_admin %}
|
||||
<button type="button" class="btn btn-sm avatar-regen-btn" data-regenerate-avatar data-username="{{ profile_user['username'] }}">Regenerate avatar</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="profile-name">{{ profile_user['username'] }}</h1>
|
||||
|
||||
@@ -546,7 +549,7 @@
|
||||
{% for person in people %}
|
||||
<div class="follow-row">
|
||||
<a href="/profile/{{ person['username'] }}" class="follow-user">
|
||||
<img src="{{ avatar_url('multiavatar', person['username'], 40) }}" class="avatar-img avatar-md" alt="{{ person['username'] }}" loading="lazy">
|
||||
<img src="{{ avatar_url('multiavatar', avatar_seed(person), 40) }}" class="avatar-img avatar-md" alt="{{ person['username'] }}" loading="lazy">
|
||||
<div class="follow-user-info">
|
||||
<span class="follow-user-name">{{ person['username'] }}</span>
|
||||
{% if person['bio'] %}<span class="follow-user-bio rendered-title">{{ render_title(person['bio']) }}</span>{% endif %}
|
||||
|
||||
@@ -9,7 +9,7 @@ from devplacepy.cache import TTLCache
|
||||
from devplacepy.config import STATIC_VERSION, TEMPLATES_DIR, TEMPLATE_AUTO_RELOAD
|
||||
from devplacepy.constants import TOPICS, REACTION_EMOJI
|
||||
from devplacepy.database import get_int_setting, get_setting, get_table
|
||||
from devplacepy.avatar import avatar_url
|
||||
from devplacepy.avatar import avatar_url, avatar_seed
|
||||
from devplacepy.utils import format_date as _format_date
|
||||
from devplacepy.utils import time_ago as _time_ago
|
||||
from devplacepy.utils import get_badge, is_admin, is_primary_admin, pretty_json
|
||||
@@ -108,6 +108,7 @@ templates.env.globals["get_unread_count"] = jinja_unread_count
|
||||
templates.env.globals["get_unread_messages"] = jinja_unread_messages
|
||||
templates.env.globals["get_user_projects"] = jinja_user_projects
|
||||
templates.env.globals["avatar_url"] = avatar_url
|
||||
templates.env.globals["avatar_seed"] = avatar_seed
|
||||
templates.env.globals["format_date"] = _format_date
|
||||
|
||||
|
||||
|
||||
@@ -613,6 +613,7 @@ def _create_account(username: str, email: str, password_hash: str) -> tuple[str,
|
||||
"ai_modifier_enabled": 1,
|
||||
"ai_modifier_prompt": DEFAULT_MODIFIER_PROMPT,
|
||||
"ai_modifier_sync": 1,
|
||||
"avatar_seed": None,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user