From 6f340a6818ebe1e5334a055c431f3ec9b43d11af Mon Sep 17 00:00:00 2001 From: retoor Date: Sat, 27 Jun 2026 22:31:34 +0000 Subject: [PATCH] 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`. --- AGENTS.md | 31 ++- README.md | 21 +- devplacepy/avatar.py | 6 + devplacepy/database.py | 49 ++++ devplacepy/docs_api.py | 45 +++- devplacepy/models.py | 4 + devplacepy/routers/devii.py | 4 +- devplacepy/routers/game/index.py | 11 + devplacepy/routers/gists.py | 6 + devplacepy/routers/issues/_shared.py | 2 +- devplacepy/routers/issues/index.py | 10 +- devplacepy/routers/messages.py | 4 + devplacepy/routers/news.py | 6 + devplacepy/routers/posts.py | 12 +- devplacepy/routers/profile/__init__.py | 2 + devplacepy/routers/profile/avatar.py | 46 ++++ devplacepy/routers/profile/index.py | 9 +- devplacepy/routers/projects/index.py | 6 + devplacepy/schemas.py | 18 ++ devplacepy/services/devii/actions/catalog.py | 36 ++- .../services/devii/actions/dispatcher.py | 1 + devplacepy/services/devrant/avatar.py | 16 +- devplacepy/services/devrant/profile.py | 3 +- devplacepy/services/devrant/serializers.py | 7 +- devplacepy/services/game/economy.py | 191 +++++++++++-- devplacepy/services/game/store.py | 255 ++++++++++++++++-- devplacepy/static/css/game.css | 24 ++ devplacepy/static/css/profile.css | 5 + devplacepy/static/js/Application.js | 2 + devplacepy/static/js/AvatarRegenerator.js | 47 ++++ devplacepy/static/js/CommentManager.js | 7 +- devplacepy/static/js/GameFarm.js | 24 +- devplacepy/templates/_avatar_link.html | 2 +- devplacepy/templates/_game_grid.html | 8 +- devplacepy/templates/_game_legacy.html | 16 ++ devplacepy/templates/_game_shop.html | 4 +- devplacepy/templates/_post_votes.html | 10 +- devplacepy/templates/base.html | 4 +- devplacepy/templates/game.html | 11 + devplacepy/templates/issue_detail.html | 4 +- devplacepy/templates/issues.html | 2 +- devplacepy/templates/messages.html | 2 +- devplacepy/templates/profile.html | 7 +- devplacepy/templating.py | 3 +- devplacepy/utils.py | 1 + tests/api/game/mutations.py | 80 ++++++ tests/api/posts/index.py | 46 ++++ tests/api/profile/index.py | 42 +++ tests/api/profile/regenerateavatar.py | 125 +++++++++ tests/api/projects/index.py | 30 +++ tests/e2e/game/index.py | 24 ++ tests/e2e/profile/regenerateavatar.py | 30 +++ tests/unit/avatar.py | 23 +- tests/unit/database.py | 74 +++++ tests/unit/models.py | 20 ++ tests/unit/schemas.py | 42 +++ tests/unit/services/devrant/__init__.py | 0 tests/unit/services/devrant/serializers.py | 48 ++++ tests/unit/services/game/economy.py | 68 +++++ tests/unit/services/game/store.py | 87 ++++++ 60 files changed, 1626 insertions(+), 97 deletions(-) create mode 100644 devplacepy/routers/profile/avatar.py create mode 100644 devplacepy/static/js/AvatarRegenerator.js create mode 100644 devplacepy/templates/_game_legacy.html create mode 100644 tests/api/profile/regenerateavatar.py create mode 100644 tests/e2e/profile/regenerateavatar.py create mode 100644 tests/unit/models.py create mode 100644 tests/unit/services/devrant/__init__.py create mode 100644 tests/unit/services/devrant/serializers.py diff --git a/AGENTS.md b/AGENTS.md index e1b8318b..ff69b44a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ make locust-headless # Locust in headless CLI mode (for CI) - **Ports:** 10500 (dev), 10501 (tests; the serial suite uses a single uvicorn subprocess) - **Username:** letters, numbers, hyphens, underscores only. 3-32 chars. - **Password:** minimum 6 chars. -- **Avatars:** Multiavatar-based (local SVG generation, zero network). URL: `/avatar/multiavatar/{seed}`. Generation takes <5ms. Fallback to initial-based SVG on error. Cache is in-memory (cleared on restart). +- **Avatars:** Multiavatar-based (local SVG generation, zero network). URL: `/avatar/multiavatar/{seed}`. Generation takes <5ms. Fallback to initial-based SVG on error. Cache is in-memory (cleared on restart). **The seed is per-user:** the nullable `users.avatar_seed` column overrides the username. Always resolve it through the single null-safe choke point `avatar.avatar_seed(user)` (registered as a Jinja global) - `user.get("avatar_seed") or user.get("username")` - never read `avatar_seed` or pass `username` to `avatar_url(...)` directly. Every render site (the `_avatar_link.html` partial, the few direct `avatar_url(...)` template calls, the SEO `og_image`, the issues ad-hoc dicts, and the devRant avatar payload/PNG) goes through it, so a regenerated seed propagates everywhere. **Regenerate** is owner-or-admin at `POST /profile/{username}/regenerate-avatar` (`routers/profile/avatar.py`, reusing `resolve_customization_target`): it 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, so the old avatar cannot return (the UI confirms once per page session with that warning; `AvatarRegenerator.js` / `[data-regenerate-avatar]`). Devii tool `regenerate_avatar` (in `CONFIRM_REQUIRED`). ## Routing @@ -123,7 +123,7 @@ The sidebar groups `section`s under four ordered **audience tiers** (`AUDIENCES` - **`data-config` must be single-quoted:** `_endpoint.html` emits `data-config='{{ endpoint|tojson }}'`. `tojson` escapes `'`→`'`, so single quotes are safe; double quotes would break. - **The reusable widget** is `static/js/ApiTester.js` (one instance per `[data-api-tester]`, wired by `ApiDocs.js` loaded in the docs `extra_js` block). It builds the param form, a **response-format picker**, live cURL/JS/Python tabs, a Send button that runs the real call, and a two-tab response area. It reads `window.DEVPLACE_DOCS` (`base`, `loggedIn`, `username`, `apiKey`, `isAdmin`) injected in `docs_base.html`. **Code blocks** are decorated by the shared `static/js/CodeBlock.js` (highlight via `hljs` + a line-number gutter + an always-visible Copy button); the widget calls `CodeBlock.refresh(pre)` on every tab switch (re-highlights - it clears `data-highlighted` first, the fix for stale tabs), the response panes use `CodeBlock.enhance(pre, {lineNumbers:false})`, and `CodeCopy.js` runs `CodeBlock.enhance` over prose `.docs-content pre`. Styling lives in `docs.css` (`.code-pre`/`.code-gutter`/`.code-has-copy`, `.format-picker`/`.format-option`, `.response-tabs`/`.response-pane`). - **Response-format negotiation in the panel:** every endpoint dict carries a `negotiation` field, set by `_classify()` in `docs_api.py` (not hand-written): `"negotiable"` (page GETs + action POSTs - toggle JSON/HTML via the `Accept` header), `"ajax"` (votes/reactions/bookmarks/polls - JSON via `X-Requested-With: fetch`, HTML shows the redirect), `"json"` (always JSON), `"none"` (avatar/proxy/redirect - no body). The picker **defaults to JSON** everywhere; `ApiTester.headerPairs()` adds `Accept: application/json|text/html` accordingly and only sends `X-Requested-With` for ajax endpoints in JSON mode, so the live request **and** the generated snippets stay in sync. The **Expected** tab (always visible, default) renders the endpoint's `sample_response`; the **Live response** tab fills in after Send. -- **Runnable scope:** page GETs are now `interactive: true` (they get a Send button, gated by `ctx.loggedIn`/`ctx.isAdmin` for user/admin endpoints). Mutations use `destructive: true` (confirm dialog). Only `avatar`, `gateway-passthrough`, `notifications-open`, `push-register`, and `profile-regenerate-key` stay `interactive: false` (image/proxy/redirect/side-effecting) - they still show the Expected tab. Admin endpoints (`auth: "admin"`) only run for admins. +- **Runnable scope:** page GETs are now `interactive: true` (they get a Send button, gated by `ctx.loggedIn`/`ctx.isAdmin` for user/admin endpoints). Mutations use `destructive: true` (confirm dialog). Only `avatar`, `gateway-passthrough`, `notifications-open`, `push-register`, `profile-regenerate-key`, and `profile-regenerate-avatar` stay `interactive: false` (image/proxy/redirect/side-effecting) - they still show the Expected tab. Admin endpoints (`auth: "admin"`) only run for admins. - **Enum params: pass `options`, never hardcode allowed values in prose.** A `field(... type="enum", options=[...])` auto-renders an "Allowed: a, b, c" line under the control in the live tester (`ApiTester.js` `buildParams`) and appends `Allowed: ...` to the Description column in the Markdown/HTML export (`docs_export.py` `_params_table`). Source enum lists from `devplacepy/constants.py` (`TOPICS`, `REACTION_EMOJI`) or the model `Literal`s (`PROJECT_TYPES`, `VOTE_TARGETS`) so docs stay in sync. Do NOT spell the values into the description - it would duplicate and drift. - **Minimal role is documented and validated:** the `endpoint()` factory derives `min_role` from `auth` (`public`→Public, `user`→Member, `admin`→Admin); it is rendered as the "Minimal role:" badge in `_endpoint.html` and as `*Minimal role:*` in the Markdown/HTML export. The `auth` value MUST reflect the real enforcement: `tests/api/auth/matrix.py` drives every documented endpoint (incl. the dynamic services group) as anonymous, member, and admin and asserts the enforcement matches the doc - public allows anonymous, `user` rejects anonymous (401/login-redirect), `admin` rejects a non-admin member (403 /feed-redirect). It forces explicit roles to survive the `is_first`-becomes-Admin rule. If you add/relax a route's auth, update its `auth` in `docs_api.py` or this test fails. (Example caught by it: `/notifications/counts` uses `get_current_user` and returns zeros to guests, so it is documented `public`, not `user`.) - **Admin-only pages:** a group with `"admin": True` (currently `services`, `admin`) is hidden from the sidebar (`docs.py` filters `visible_pages`) and returns 404 for non-admins (`page.get("admin") and not is_admin → not_found`, same message as unknown slugs). `index.html` wraps the operator links in `{% if user and user.get('role') == 'Admin' %}`. @@ -1164,6 +1164,26 @@ The top-nav bell and Messages link carry `data-counter="notifications"` / `data- `level`/`badge` notifications have `related_uid == user_uid` (self), so the notifications template renders them with the recipient's own avatar; the template is type-agnostic (driven by `message`/`actor`), so no per-type handling is needed. +### Auto-mark-read on content view + +Beyond the explicit clears (`/notifications/open/{uid}`, `POST /notifications/mark-read/{uid}`, `POST /notifications/mark-all-read`), a notification is **automatically marked read when the user opens the page where its referenced content is visible**. The single primitive is `database.mark_notifications_read_by_target(user_uid, target_url)`: it stamps `read = 1` on every unread notification of that user whose stored `target_url` equals `target_url` OR begins with `target_url + "#"` (so a post-page key clears its `#comment-{uid}` variants), then `clear_unread_cache(user_uid)`. It returns the count and short-circuits (no write, no cache bump) when nothing matches, so a detail view by a user with no relevant unread notifications costs only one indexed read. There is **no** `target_type`/`target_uid` column - the `target_url` string (produced by `resolve_object_url`) is the only content key, so callers build the page key with that same generator (or the identical literal the create-site used) to guarantee a match with zero drift. + +Call sites (each only when a real user is present and the resource resolved, placed after the 404/canonical-redirect guards): + +| GET handler | page key | clears | +|-------------|----------|--------| +| `posts.py::view_post` | `resolve_object_url("post", uid)` | comment, reply, vote, mention (post + its comments) | +| `projects/index.py::project_detail` | `resolve_object_url("project", uid)` | vote, mention | +| `gists.py::gist_detail` | `resolve_object_url("gist", uid)` | vote, mention | +| `news.py::news_detail_page` | `resolve_object_url("news", uid)` | comment, vote, mention | +| `messages.py::messages_page` | `/messages?with_uid={with_uid}` | message, DM mention (beside the existing `mark_conversation_read`) | +| `profile/index.py::profile_page` | `/profile/{username}` | follow (follower's profile), badge/level (own profile) | +| `issues/index.py::issue_detail` | `/issues?highlight={number}` | issue (the create-sites store the `?highlight=` URL, not the `/issues/{number}` detail path) | +| `devii.py::devii_page` | `/devii` | reminder | +| `game/index.py::game_home` | `/game` | harvest_stolen | + +Deliberately excluded: `GET /notifications` (the list must stay readable) and `/feed` (not a content page). Reuse `mark_notifications_read_by_target` for any future surface; never hand-roll a per-handler `UPDATE notifications`. + ### Time-grouped display Notifications are grouped by time period in `notifications.py` `_group_label()`: @@ -2400,9 +2420,10 @@ A general-purpose, **database-free** publish/subscribe bus mounted at `/pubsub`, A cooperative-and-competitive idle game (Farmville-style) mounted at `/game`, member-only to play, public to view another farm. Cooperative loop = watering a neighbour's growing build; competitive loop = stealing a neighbour's ready build. - **Data layer is pure + timestamp-driven (no background tick).** `services/game/economy.py` holds every constant and formula as frozen dataclasses/functions: the `CROPS` tuple (key/name/icon/cost/grow_seconds/reward_coins/reward_xp/min_level), `CI_TIERS` (speed multiplier + upgrade cost), level thresholds (`xp_threshold`/`level_for_xp`/`level_progress`), `plot_cost` (doubles per extra plot), and watering bonus. `services/game/store.py` is the only DB access (`game_farms`, `game_plots`). **A plot's state is derived from `ready_at` vs now, never stored** - growing crops finish purely by the clock, so there is no reconciler/service. `serialize_farm(farm, viewer=, owner=)` computes plot states, remaining seconds, `can_water` (viewer is not owner, build growing, viewer not already in the per-cycle `watered_by` JSON list, under `MAX_WATERS_PER_PLOT`), `can_steal`/`steal_coins` (viewer is not owner, build ready, and `now >= ready_at + STEAL_GRACE_SECONDS` so the owner gets a protection window), level progress, and the plantable crop list. `serialize_plot` takes the owner farm's `yield_level`/`prestige` (threaded from `serialize_farm`) only to compute the steal payout. Mutations (`plant`/`harvest`/`buy_plot`/`upgrade_ci`/`water`/`steal`) raise `GameError` on any invalid op (insufficient coins, locked crop, wrong state, still-protected harvest); the routers translate that to a `400` JSON error or a redirect. -- **Tables** `game_farms` (one per user, `coins`/`xp`/`level`/`ci_tier`/`plot_count`/`total_harvests`) and `game_plots` (`farm_uid`/`slot_index`/`crop_key`/`planted_at`/`ready_at`/`watered_by`) are **not** soft-deletable - they are mutable game state consumed by state transitions, not user content. Columns + indexes are ensured in `database.init_db` (unique `idx_game_farms_user`, `idx_game_farms_rank`, `idx_game_plots_farm`); the `_uid_index` loop adds the uid index. `ensure_farm(user_uid)` lazily creates a farm + starting plots on first access (idempotent), so there is no signup hook. +- **Tables** `game_farms` (one per user, `coins`/`xp`/`level`/`ci_tier`/`plot_count`/`total_harvests`, plus `prestige`/`streak`/`last_daily_at`, the four `perk_*` columns, and the endgame `stars` + five `legacy_*` columns), `game_plots` (`farm_uid`/`slot_index`/`crop_key`/`planted_at`/`ready_at`/`watered_by`), and `game_steals` (`thief_uid`/`owner_uid`/`slot_index`/`crop_key`/`coins`/`stolen_at`, the per-pair steal-cooldown ledger) are **not** soft-deletable - they are mutable game state consumed by state transitions, not user content. Columns + indexes are ensured in `database.init_db` (unique `idx_game_farms_user`, `idx_game_farms_rank`, `idx_game_plots_farm`, `idx_game_steals_pair` on `(thief_uid, owner_uid, stolen_at)`); the `_uid_index` loop adds the uid index. `ensure_farm(user_uid)` lazily creates a farm + starting plots on first access (idempotent), so there is no signup hook. - **Routes (`routers/game/`):** `index.py` is the base router - `GET /game` (own farm page), `GET /game/state` (own farm JSON), `GET /game/leaderboard`, and the action POSTs `plant`/`harvest`/`buy-plot`/`upgrade`. `farm.py` adds `GET /game/farm/{username}` (view), `POST /game/farm/{username}/water`, and `POST /game/farm/{username}/steal`. Every action returns the **full updated farm** as JSON (so the client refreshes in one round trip) or redirects for no-JS, via the shared `_respond_action` choke (the `farm.py` water/steal handlers inline the same shape). Harvest awards site XP (`award_rewards`) and the `harvest`/`water` achievements (`track_action`). All action handlers are `require_user`; reads of other farms/the leaderboard are public. **Leaderboard ranking is a composite `economy.farm_score(farm)`** (one integer per row, computed in memory over the already-loaded `_farms().find()` set, so it stays fast): it sums weighted contributions from every tracked factor - `xp`, `prestige` * `SCORE_PRESTIGE` (5000, dominant since a refactor is a full completed cycle), `total_harvests` * `SCORE_HARVEST`, `coins` // `SCORE_COIN_DIVISOR`, `(ci_tier-1)` * `SCORE_CI`, `(plot_count-STARTING_PLOTS)` * `SCORE_PLOT`, the summed perk levels * `SCORE_PERK`, and `min(streak, SCORE_STREAK_CAP)` * `SCORE_STREAK` - so a player who refactored (which resets xp/level/coins/ci/plots/perks) is no longer buried below a never-refactored higher-level player. The weights are module-level constants in `economy.py` for tuning; the leaderboard entry carries `score` and `prestige` (surfaced on `GameLeaderboardEntryOut`) and `GameFarm.js` renders the score next to `Lv X`. - **Live + frontend.** After any mutation the handler `await`s `_shared.notify_farm(username)` which publishes a nudge to the `public.game.farm.{username}` pub/sub topic; subscribed clients re-fetch their viewer-specific state (keeps `can_water` correct without broadcasting per-viewer payloads). `static/js/GameFarm.js` (`app.gameFarm`, auto-detects `[data-game-root]`) renders the grid/HUD/leaderboard, ticks plot countdowns client-side every second, delegates the `data-game-action` forms through `Http.send`, subscribes to the farm topic, and keeps a 20s `Poller` fallback. The server renders a full no-JS fallback grid (`templates/_game_grid.html`, shared by `game.html` and `game_farm.html` with progressive-enhancement POST forms). - **Fan-out.** Devii plays via the `game_*` `http` actions in `actions/catalog.py` (state/leaderboard/view public, the rest `requires_auth`); the API reference has a **Code Farm** group in `docs_api.py`; pages are `noindex,follow` (interactive, user-specific) so they are intentionally not in the sitemap; badges live in `utils.BADGE_CATALOG` under the **Code Farm** group with `harvest`/`water`/`harvest_stolen`/`got_stolen_from` `ACHIEVEMENTS` (the steal pair awards **Cat Burglar** to the thief and **Robbed** to the victim, both threshold 1). -- **Stealing (competitive loop, backwards compatible).** `store.steal(thief, owner, slot)` mirrors `water`: it requires the owner plot to be `ready` AND past the `economy.STEAL_GRACE_SECONDS` (60s) protection window measured from `ready_at`, clears the plot exactly like a harvest (owner gets nothing), credits the **thief's** farm `economy.steal_reward_coins` (half the realized harvest value via `STEAL_FRACTION`, computed from the owner's yield/prestige) and **coins only** - no XP, no `total_harvests`, so the leaderboard stays earned by real farming. The route `POST /game/farm/{username}/steal` (reusing `GameSlotForm`) then `track_action`s both sides, fires `create_notification(owner, "harvest_stolen", "Someone raided your Code Farm...", thief_uid, "/game")` (the message never names the thief; `related_uid` is internal), and `await notify_farm`s **both** the owner and the thief usernames so both farms refresh live. The new `harvest_stolen` notification type rides the existing in-app relay (live toast, no new wiring). No DB migration: grace + payout are computed from existing `ready_at` and perk columns, and the new `GamePlotOut.can_steal`/`steal_coins` + `GameFarmViewOut.stole_coins` schema fields default safe. Frontend: the ready/not-owner branch of `_game_grid.html` and `GameFarm.js._plotHtml` render a `btn-danger` Steal button (`data-game-action="steal"`, `data-confirm` gated like prestige) carrying `steal_coins`; on success `GameFarm.js._submit` toasts the payout from `data.stole_coins`. -- **Extended mechanics (all backwards compatible).** Five further systems layer onto the base loop, every one defaulting gracefully for pre-existing `game_farms`/`game_plots` rows: new `game_farms` columns (`prestige`, `streak`, `last_daily_at`, `perk_yield`/`perk_growth`/`perk_discount`/`perk_xp`) are added in the `init_db` ensure-block, and `store._lvl(farm, key)` reads every one as `int(farm.get(key) or 0)` so a legacy NULL row behaves as level 0 / no streak / no perks. (1) **Daily bonus** (`POST /game/daily`, `store.claim_daily`): once per UTC day, consecutive days grow `streak` (reward `economy.daily_reward`, capped at `DAILY_STREAK_CAP`). (2) **Daily quests** (`game_quests` table, `POST /game/quests/claim`): `economy.daily_quests(user_uid, day)` deterministically (sha256 of `user:day`) picks 3 of {plant, harvest, water, earn} with goals/rewards; `store.ensure_quests` lazily materializes the day's rows, `store.advance_quests(user_uid, kind, amount)` is called inside `plant`/`harvest`/`water` (wrapped in try/except so a quest write never breaks the action), `claim_quest` pays out when `progress >= goal`. (3) **Perks** (`POST /game/perk`, `store.upgrade_perk`): four permanent upgrades (`economy.PERKS`) with escalating `perk_cost`; applied in the economy formulas - `effective_plant_cost` (discount), `grow_seconds_for(crop, ci_tier, growth_level)` via `farm_speed` (growth), `effective_reward_coins` (yield + prestige), `effective_reward_xp` (xp). (4) **Fertilizer** (`POST /game/fertilize`, `store.fertilize`): pay `economy.fertilize_cost(remaining)` to cut a growing plot's `ready_at` by `FERTILIZE_FRACTION`. (5) **Prestige/Refactor** (`POST /game/prestige`, `store.prestige`): at `PRESTIGE_MIN_LEVEL` resets coins/xp/level/ci/perks and deletes plots beyond `STARTING_PLOTS`, incrementing `prestige` for a permanent `prestige_multiplier` (+25% coins each). `serialize_farm` exposes all of this (`perks`, `quests`, `streak`, `daily_available`/`daily_reward`, `prestige*`) only to the owner; the existing `crop_payload`/`serialize_plot` now carry perk-adjusted costs/rewards and per-plot `fertilize_cost`. Frontend hosts (`[data-shop-host]`/`[data-perk-host]`/`[data-daily-host]`/`[data-quest-host]`) are server-rendered from partials (`_game_shop.html`/`_game_perks.html`/`_game_daily.html`/`_game_quests.html`) and fully re-rendered by `GameFarm.js` builders; the generic `data-game-action` form delegation handles the new actions, with `data-confirm` gating the prestige reset. +- **Stealing (competitive loop, backwards compatible).** `store.steal(thief, owner, slot)` mirrors `water`: it requires the owner plot to be `ready` AND past the protection window `economy.effective_steal_grace(owner_defense_level)` (base `STEAL_GRACE_SECONDS` 60s, +30s per owner Branch Protection level) measured from `ready_at`, AND that the thief is **off cooldown for this victim** (`steal_cooldown_remaining(thief, owner, now) == 0`, i.e. no row in `game_steals` for the pair within `STEAL_COOLDOWN_SECONDS` = 3600 - you can raid a given neighbour only once per hour); it clears the plot exactly like a harvest (owner gets nothing), credits the **thief's** farm `economy.steal_reward_coins` (the owner's yield/prestige/legacy-multiplier realized value times `effective_steal_fraction(owner_defense_level)`, base `STEAL_FRACTION` 0.5, -5% per defense level, floored at 0.1) and **coins only** - no XP, no `total_harvests`, so the leaderboard stays earned by real farming - then inserts a `game_steals` row stamping the cooldown. The route `POST /game/farm/{username}/steal` (reusing `GameSlotForm`) then `track_action`s both sides, fires `create_notification(owner, "harvest_stolen", "Someone raided your Code Farm...", thief_uid, "/game")` (the message never names the thief; `related_uid` is internal), and `await notify_farm`s **both** the owner and the thief usernames so both farms refresh live. The new `harvest_stolen` notification type rides the existing in-app relay (live toast, no new wiring). No DB migration: grace + payout are computed from existing `ready_at`/perk/legacy columns, the `game_steals` table is created by the ensure-block (absent rows -> cooldown 0 -> first steal always allowed), and the new `GamePlotOut.can_steal`/`steal_coins`/`steal_cooldown_seconds`/`steal_reason` + `GameFarmOut.steal_cooldown_seconds` + `GameFarmViewOut.stole_coins` schema fields default safe. The per-pair cooldown is computed **once per farm** in `serialize_farm` (when the viewer is not the owner) and threaded into every `serialize_plot` as `steal_locked_until`, so `can_steal` is false and `steal_reason` is `"cooldown"`/`"protected"` accordingly. Frontend: the ready/not-owner branch of `_game_grid.html` and `GameFarm.js._plotHtml` render a `btn-danger` Steal button (`data-game-action="steal"`, `data-confirm` gated like prestige) carrying `steal_coins`, or a disabled "Raid again in " label when on cooldown; on success `GameFarm.js._submit` toasts the payout from `data.stole_coins`. +- **Extended mechanics (all backwards compatible).** Five further systems layer onto the base loop, every one defaulting gracefully for pre-existing `game_farms`/`game_plots` rows: new `game_farms` columns (`prestige`, `streak`, `last_daily_at`, `perk_yield`/`perk_growth`/`perk_discount`/`perk_xp`) are added in the `init_db` ensure-block, and `store._lvl(farm, key)` reads every one as `int(farm.get(key) or 0)` so a legacy NULL row behaves as level 0 / no streak / no perks. (1) **Daily bonus** (`POST /game/daily`, `store.claim_daily`): once per UTC day, consecutive days grow `streak` (reward `economy.daily_reward`, capped at `DAILY_STREAK_CAP`). (2) **Daily quests** (`game_quests` table, `POST /game/quests/claim`): `economy.daily_quests(user_uid, day)` deterministically (sha256 of `user:day`) picks 3 of {plant, harvest, water, earn} with goals/rewards; `store.ensure_quests` lazily materializes the day's rows, `store.advance_quests(user_uid, kind, amount)` is called inside `plant`/`harvest`/`water` (wrapped in try/except so a quest write never breaks the action), `claim_quest` pays out when `progress >= goal`. (3) **Perks** (`POST /game/perk`, `store.upgrade_perk`): four permanent upgrades (`economy.PERKS`) with escalating `perk_cost`; applied in the economy formulas - `effective_plant_cost` (discount), `grow_seconds_for(crop, ci_tier, growth_level)` via `farm_speed` (growth), `effective_reward_coins` (yield + prestige), `effective_reward_xp` (xp). (4) **Fertilizer** (`POST /game/fertilize`, `store.fertilize`): cut a growing plot's `ready_at` by `FERTILIZE_FRACTION` for `economy.fertilize_click_cost(eff_reward, reduce_seconds, full_grow_seconds)` = `ceil(eff_reward * reduce/full_grow * FERTILIZE_TAX)` (TAX 1.05). **The cost is priced against the build's realized harvest value (`effective_reward_coins`, which already carries yield/prestige/legacy multipliers), not raw grow-seconds, so the prestige dependence cancels and fully fertilizing a crop always costs >= its harvest - fertilize is a pure time-skip and can NEVER be a profit at any prestige.** (This replaced the old grow-seconds-based `fertilize_cost`, which was an unbounded money pump at high prestige.) (5) **Prestige/Refactor** (`POST /game/prestige`, `store.prestige`): at `PRESTIGE_MIN_LEVEL` resets coins/xp/level/ci/perks, sets `plot_count = economy.prestige_base_plots(legacy_plots_level)` (keeps/recreates plot rows up to that base, deletes the rest), increments `prestige` for a permanent `prestige_multiplier` (+25% coins each), and awards `economy.stars_for_refactor(level, prestige)` Stars (the `legacy_*`/`stars` columns are **omitted from the reset dict** so they survive every refactor, the established pattern). `serialize_farm` exposes all of this (`perks`, `quests`, `streak`, `daily_available`/`daily_reward`, `prestige*`, `stars`, `legacy`, `steal_cooldown_seconds`) only to the owner; the existing `crop_payload`/`serialize_plot` now carry perk- and legacy-adjusted costs/rewards and per-plot value-based `fertilize_cost`. Frontend hosts (`[data-shop-host]`/`[data-perk-host]`/`[data-legacy-host]`/`[data-daily-host]`/`[data-quest-host]`) are server-rendered from partials (`_game_shop.html`/`_game_perks.html`/`_game_legacy.html`/`_game_daily.html`/`_game_quests.html`) and fully re-rendered by `GameFarm.js` builders; the generic `data-game-action` form delegation handles the new actions, with `data-confirm` gating the prestige reset. +- **Endgame: Stars, Legacy upgrades, auto-harvest, golden builds (all backwards compatible).** The infinite progression for maxed farms. Six new default-0 `game_farms` columns (`stars`, `legacy_autoharvest`, `legacy_multiplier`, `legacy_speed`, `legacy_plots`, `legacy_defense`), all read via `_lvl`. **Stars** are a meta-currency earned only on Refactor (`stars_for_refactor`), spent via `POST /game/legacy` (`store.upgrade_legacy`, `GameLegacyForm`, Devii `game_upgrade_legacy`) on `economy.LEGACY_UPGRADES` (escalating `legacy_cost` in Stars) that **survive prestige** unlike perks: `autoharvest` (CI Bot), `multiplier` (+10% coins/lvl via `legacy_multiplier`, folded into `effective_reward_coins`), `speed` (+5% base build speed/lvl via `farm_speed`/`grow_seconds_for`/`water_bonus_seconds`), `plots` (+1 base plot after refactor via `prestige_base_plots`), `defense` (steal grace/fraction via `effective_steal_grace`/`effective_steal_fraction`). **Auto-harvest** is lazy and tick-free: `store._auto_harvest(farm, owner_uid, now)` runs at the top of `serialize_farm` ONLY when the viewer is the owner AND `legacy_autoharvest > 0`; it **clears each ready plot first then credits** the pre-clear crop's coins/xp/`total_harvests` in one `_update_farm`, advances quests, and re-reads the farm - clear-then-credit is idempotent (a second read sees empty plots), and it never fires on a visitor's `GET /game/farm/{username}` view or the leaderboard, honouring the no-background-service invariant (both `GET /game` and `GET /game/state` go through `state_payload` with viewer=owner, so both auto-collect). **Golden builds** are deterministic and storage-free: `economy.is_golden(plot_uid, planted_at)` (sha256, ~`GOLDEN_CHANCE`) marks a planting golden for its life; `harvest`/`_auto_harvest` multiply **coins only** by `GOLDEN_MULTIPLIER` (XP unscaled to keep level pacing), and `serialize_plot.is_golden` surfaces a sparkle badge (`.game-plot-golden`). New schema fields (`GameLegacyOut`, `GameFarmOut.stars`/`legacy`, `GamePlotOut.is_golden`) all default safe; no DB migration. diff --git a/README.md b/README.md index 8c032a74..35cf019c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Open `http://localhost:10500`. | Frontend | Pure ES6 JavaScript, one class per file | | Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) | | Auth | Session cookie, API key (`X-API-KEY`/Bearer), or HTTP Basic; PBKDF2-SHA256 via passlib | -| Avatars | Multiavatar (local SVG generation, no external API, <5ms) | +| Avatars | Multiavatar (local SVG generation, no external API, <5ms). Seeded from the username by default; a per-user `avatar_seed` lets the owner or an admin regenerate a fresh random avatar from the profile page (`POST /profile/{username}/regenerate-avatar`). Regeneration is irreversible - the previous avatar cannot be recovered. | | Outbound HTTP | Stealth client (`devplacepy/stealth.py`): real Chrome fingerprint (TLS JA3/JA4 + HTTP/2 + headers) via `curl_cffi` behind an `httpx` transport adapter, pure-`httpx[http2]` fallback; the single client for every server-side outbound request | | Coverage | `coverage.py` (`.coveragerc`, subprocess-aware) | | Load testing | Locust (locustfile.py) | @@ -119,16 +119,18 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill - **Build and harvest.** A planted crop builds over real time; when the build finishes, harvest it for coins and XP. Harvesting also awards site XP and the **Green Thumb** / **Master Farmer** badges. - **Upgrade CI.** Spend coins to raise your CI tier (Local Build through Distributed Cache); each tier makes every build faster. - **Buy plots.** Unlock more plots (up to twelve); each new plot costs more than the last. -- **Fertilize.** Spend coins on a growing build to instantly halve its remaining time. +- **Fertilize.** Spend coins on a growing build to halve its remaining time. The cost is priced against the build's realized harvest value, so fertilizing is a pure time-skip - it brings the harvest sooner but never returns more coins than it costs, at any prestige level. - **Daily bonus.** Claim a coin bonus once per day; consecutive days build a streak that grows the reward (capped at seven days). - **Daily quests.** Three quests rotate every day (plant, harvest, water, or earn goals), tracked automatically as you play; claim each one for coins and XP when complete. - **Perks.** Spend coins on four permanent upgrades - Optimizer (+harvest coins), Build Cache (+build speed), Bulk Licenses (-planting cost), and Mentorship (+harvest XP) - each levelling up with escalating cost. -- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. +- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop. +- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), and **Branch Protection** (longer steal grace and a smaller steal cut). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off. +- **Golden builds.** A small share of plantings come out golden (marked with a sparkle); harvesting a golden build pays several times the coins. - **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping, and the owner sees the help live. This is the social loop that makes the game cooperative. -- **Steal a harvest.** A ready build on someone else's farm can be stolen once a 60-second protection window passes - the owner gets that grace period to harvest it first. A successful steal pays the thief half the build's coin value (the owner loses the whole build) and earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a live notification that someone raided their farm (the thief is never named). Stealing pays coins only, so the leaderboard stays earned by real farming. This is the competitive counterpart to watering. +- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection) to harvest it first. A successful steal pays the thief half the build's coin value (the owner loses the whole build) and earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a live notification that someone raided their farm (the thief is never named). You can raid any given neighbour only **once per hour**, so no one has to babysit their farm against constant theft. Stealing pays coins only, so the leaderboard stays earned by real farming. This is the competitive counterpart to watering. - **Leaderboard.** Top farmers are ranked by a composite achievement score that weighs every factor the game tracks - refactor (prestige) count, XP, lifetime harvests, current coins, CI tier, plots bought, perk levels, and login streak - so total accomplishment decides position rather than just the current post-refactor cycle. The score is shown alongside your own farm next to each player's level. -The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`). See the API reference group **Code Farm**. +The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`). See the API reference group **Code Farm**. ## Engagement @@ -290,7 +292,7 @@ audit, and soft-delete all apply. | GET / POST / DELETE | `/api/comments/{id}` | Read / edit / delete a comment | | POST | `/api/comments/{id}/vote` | Vote on a comment | | GET / DELETE | `/api/users/me/notif-feed` | Notification feed / mark all read | -| GET | `/api/avatars/u/{username}.png` | PNG avatar rendered from the username seed | +| GET | `/api/avatars/u/{seed}.png` | PNG avatar rendered from the user's avatar seed (the regenerated `avatar_seed`, or the username when unset) | devRant `tags` round-trip verbatim via a `tags` column on `posts`; `profile_skills` is derived from the user bio (DevPlace has no separate skills field); avatars are real PNGs rendered from @@ -737,6 +739,13 @@ subscription or push-service error never blocks the triggering request. Delivery (legacy `aesgcm` content encoding), and POSTs to each endpoint; subscriptions that return `404`/`410` are soft-deleted. +A notification is also **marked read automatically when you open the page that shows its +content** - viewing a post clears its comment, reply, upvote and mention notifications; +opening a conversation clears its direct-message notifications; visiting a profile clears +the matching follow, badge and level notifications; and the issue, reminder and farm-raid +notifications clear on their respective pages. You no longer have to dismiss each one by +hand after reading the content it points to. + ### Configurable notifications Every notification type can be turned on or off per channel, per user. The **Notifications** diff --git a/devplacepy/avatar.py b/devplacepy/avatar.py index e81c6f0a..2eccd679 100644 --- a/devplacepy/avatar.py +++ b/devplacepy/avatar.py @@ -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 diff --git a/devplacepy/database.py b/devplacepy/database.py index 13f468c8..4bef16f0 100644 --- a/devplacepy/database.py +++ b/devplacepy/database.py @@ -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: diff --git a/devplacepy/docs_api.py b/devplacepy/docs_api.py index 30ffeed5..9073385e 100644 --- a/devplacepy/docs_api.py +++ b/devplacepy/docs_api.py @@ -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}}, + ), ], }, ] diff --git a/devplacepy/models.py b/devplacepy/models.py index ea38b9f3..5be4d872 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -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) diff --git a/devplacepy/routers/devii.py b/devplacepy/routers/devii.py index f3ebc26c..6ab7b939 100644 --- a/devplacepy/routers/devii.py +++ b/devplacepy/routers/devii.py @@ -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", diff --git a/devplacepy/routers/game/index.py b/devplacepy/routers/game/index.py index 3d2db24f..56ecda4a 100644 --- a/devplacepy/routers/game/index.py +++ b/devplacepy/routers/game/index.py @@ -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) diff --git a/devplacepy/routers/gists.py b/devplacepy/routers/gists.py index 5c86d73f..aeab6b5c 100644 --- a/devplacepy/routers/gists.py +++ b/devplacepy/routers/gists.py @@ -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( diff --git a/devplacepy/routers/issues/_shared.py b/devplacepy/routers/issues/_shared.py index 06099ad1..d35dc3fc 100644 --- a/devplacepy/routers/issues/_shared.py +++ b/devplacepy/routers/issues/_shared.py @@ -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 { diff --git a/devplacepy/routers/issues/index.py b/devplacepy/routers/issues/index.py index e20338cc..1fee7102 100644 --- a/devplacepy/routers/issues/index.py +++ b/devplacepy/routers/issues/index.py @@ -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] diff --git a/devplacepy/routers/messages.py b/devplacepy/routers/messages.py index 2fb0fa68..1b915062 100644 --- a/devplacepy/routers/messages.py +++ b/devplacepy/routers/messages.py @@ -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) diff --git a/devplacepy/routers/news.py b/devplacepy/routers/news.py index a579fc48..4a3902b3 100644 --- a/devplacepy/routers/news.py +++ b/devplacepy/routers/news.py @@ -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: diff --git a/devplacepy/routers/posts.py b/devplacepy/routers/posts.py index 50760d01..5c033f04 100644 --- a/devplacepy/routers/posts.py +++ b/devplacepy/routers/posts.py @@ -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"] diff --git a/devplacepy/routers/profile/__init__.py b/devplacepy/routers/profile/__init__.py index b2af7f55..5f097eb1 100644 --- a/devplacepy/routers/profile/__init__.py +++ b/devplacepy/routers/profile/__init__.py @@ -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"] diff --git a/devplacepy/routers/profile/avatar.py b/devplacepy/routers/profile/avatar.py new file mode 100644 index 00000000..989357dd --- /dev/null +++ b/devplacepy/routers/profile/avatar.py @@ -0,0 +1,46 @@ +# retoor + +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), + }, + ) diff --git a/devplacepy/routers/profile/index.py b/devplacepy/routers/profile/index.py index d9c66838..7bd9c551 100644 --- a/devplacepy/routers/profile/index.py +++ b/devplacepy/routers/profile/index.py @@ -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"}, { diff --git a/devplacepy/routers/projects/index.py b/devplacepy/routers/projects/index.py index 43b0020e..8c3c809b 100644 --- a/devplacepy/routers/projects/index.py +++ b/devplacepy/routers/projects/index.py @@ -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" diff --git a/devplacepy/schemas.py b/devplacepy/schemas.py index 6360de93..a11618d0 100644 --- a/devplacepy/schemas.py +++ b/devplacepy/schemas.py @@ -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): diff --git a/devplacepy/services/devii/actions/catalog.py b/devplacepy/services/devii/actions/catalog.py index 8fc76588..184d0587 100644 --- a/devplacepy/services/devii/actions/catalog.py +++ b/devplacepy/services/devii/actions/catalog.py @@ -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) diff --git a/devplacepy/services/devii/actions/dispatcher.py b/devplacepy/services/devii/actions/dispatcher.py index ff6a4fa0..b3aa2573 100644 --- a/devplacepy/services/devii/actions/dispatcher.py +++ b/devplacepy/services/devii/actions/dispatcher.py @@ -41,6 +41,7 @@ CONFIRM_REQUIRED = { "delete_project", "delete_media", "regenerate_api_key", + "regenerate_avatar", "delete_post", "delete_comment", "delete_gist", diff --git a/devplacepy/services/devrant/avatar.py b/devplacepy/services/devrant/avatar.py index 65432ab5..bd447ed3 100644 --- a/devplacepy/services/devrant/avatar.py +++ b/devplacepy/services/devrant/avatar.py @@ -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 ) diff --git a/devplacepy/services/devrant/profile.py b/devplacepy/services/devrant/profile.py index fc150045..6237d1cc 100644 --- a/devplacepy/services/devrant/profile.py +++ b/devplacepy/services/devrant/profile.py @@ -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, diff --git a/devplacepy/services/devrant/serializers.py b/devplacepy/services/devrant/serializers.py index 462fc5bb..6b16ecbd 100644 --- a/devplacepy/services/devrant/serializers.py +++ b/devplacepy/services/devrant/serializers.py @@ -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)), } diff --git a/devplacepy/services/game/economy.py b/devplacepy/services/game/economy.py index 6365f268..722791fc 100644 --- a/devplacepy/services/game/economy.py +++ b/devplacepy/services/game/economy.py @@ -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) diff --git a/devplacepy/services/game/store.py b/devplacepy/services/game/store.py index ce0c871b..5ad0d5e4 100644 --- a/devplacepy/services/game/store.py +++ b/devplacepy/services/game/store.py @@ -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)}, diff --git a/devplacepy/static/css/game.css b/devplacepy/static/css/game.css index 38f3766f..44cf1c7e 100644 --- a/devplacepy/static/css/game.css +++ b/devplacepy/static/css/game.css @@ -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); +} diff --git a/devplacepy/static/css/profile.css b/devplacepy/static/css/profile.css index b04e5ba9..8204ebf3 100644 --- a/devplacepy/static/css/profile.css +++ b/devplacepy/static/css/profile.css @@ -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; diff --git a/devplacepy/static/js/Application.js b/devplacepy/static/js/Application.js index cc1c68e7..52b91508 100644 --- a/devplacepy/static/js/Application.js +++ b/devplacepy/static/js/Application.js @@ -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(); diff --git a/devplacepy/static/js/AvatarRegenerator.js b/devplacepy/static/js/AvatarRegenerator.js new file mode 100644 index 00000000..6c3bc66c --- /dev/null +++ b/devplacepy/static/js/AvatarRegenerator.js @@ -0,0 +1,47 @@ +// retoor + +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 }; diff --git a/devplacepy/static/js/CommentManager.js b/devplacepy/static/js/CommentManager.js index b4b6979c..5493c774 100644 --- a/devplacepy/static/js/CommentManager.js +++ b/devplacepy/static/js/CommentManager.js @@ -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) { diff --git a/devplacepy/static/js/GameFarm.js b/devplacepy/static/js/GameFarm.js index ad646eb8..ec38443e 100644 --- a/devplacepy/static/js/GameFarm.js +++ b/devplacepy/static/js/GameFarm.js @@ -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 + ? `Maxed` + : `
`; + return `
${up.name}Lv ${up.level}/${up.max_level}${up.effect || up.description}${action}
`; + }) + .join(""); + } + _dailyHtml(farm) { const action = farm.daily_available ? `
` @@ -164,7 +177,8 @@ export class GameFarm { } _plotHtml(plot, farm) { - const head = `
`; + const golden = plot.is_golden ? " game-plot-golden" : ""; + const head = `
`; let body = ""; if (plot.state === "empty") { if (farm.is_owner) { @@ -188,11 +202,15 @@ export class GameFarm { body += `
`; } } else { - body = `${plot.crop_name}`; + const goldenMark = plot.is_golden ? " ✨" : ""; + body = `${plot.crop_name}${goldenMark}`; if (farm.is_owner) { - body += `
`; + const label = plot.is_golden ? "Harvest golden" : "Harvest"; + body += `
`; } else if (plot.can_steal) { body += `
`; + } else if (plot.steal_reason === "cooldown") { + body += `Raid again in ${this._format(plot.steal_cooldown_seconds)}`; } else { body += `Ready`; } diff --git a/devplacepy/templates/_avatar_link.html b/devplacepy/templates/_avatar_link.html index 557231a0..aa1369d3 100644 --- a/devplacepy/templates/_avatar_link.html +++ b/devplacepy/templates/_avatar_link.html @@ -1,5 +1,5 @@ {% if _user %} -{{ _user['username'] }} +{{ _user['username'] }} {% endif %} \ No newline at end of file diff --git a/devplacepy/templates/_game_grid.html b/devplacepy/templates/_game_grid.html index 8f93ab78..1096ef48 100644 --- a/devplacepy/templates/_game_grid.html +++ b/devplacepy/templates/_game_grid.html @@ -1,6 +1,6 @@
{% for plot in farm.plots %} -
+
{% if plot.state == 'empty' %} {% if farm.is_owner %}
@@ -34,17 +34,19 @@ {% endif %} {% else %} - {{ plot.crop_name }} + {{ plot.crop_name }}{% if plot.is_golden %} ✨{% endif %} {% if farm.is_owner %} - +
{% elif plot.can_steal %}
+ {% elif plot.steal_reason == 'cooldown' %} + Raided recently {% else %} Ready {% endif %} diff --git a/devplacepy/templates/_game_legacy.html b/devplacepy/templates/_game_legacy.html new file mode 100644 index 00000000..0631c7c7 --- /dev/null +++ b/devplacepy/templates/_game_legacy.html @@ -0,0 +1,16 @@ +{% for up in farm.legacy %} +
+ + {{ up.name }} + Lv {{ up.level }}/{{ up.max_level }} + {{ up.effect or up.description }} + {% if up.maxed %} + Maxed + {% else %} +
+ + +
+ {% endif %} +
+{% endfor %} diff --git a/devplacepy/templates/_game_shop.html b/devplacepy/templates/_game_shop.html index cfbe8a39..dabed06b 100644 --- a/devplacepy/templates/_game_shop.html +++ b/devplacepy/templates/_game_shop.html @@ -12,11 +12,11 @@
Refactor (prestige {{ farm.prestige }}) - {% if farm.prestige_available %}Reset your farm for a permanent +25% coin bonus.{% else %}Reach level {{ farm.prestige_min_level }} to refactor.{% endif %} + {% 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 %}
{% if farm.prestige_available %}
- +
{% endif %}
diff --git a/devplacepy/templates/_post_votes.html b/devplacepy/templates/_post_votes.html index 75946072..6cd72bc3 100644 --- a/devplacepy/templates/_post_votes.html +++ b/devplacepy/templates/_post_votes.html @@ -1,11 +1,11 @@
-
- - -
- {{ _count }}
+ {{ _count }} +
+ + +
diff --git a/devplacepy/templates/base.html b/devplacepy/templates/base.html index 10a2ef93..88119f8e 100644 --- a/devplacepy/templates/base.html +++ b/devplacepy/templates/base.html @@ -103,7 +103,7 @@ {% endif %}
+ {% endif %}

{{ profile_user['username'] }}

@@ -546,7 +549,7 @@ {% for person in people %}