Compare commits

..

1 Commits

Author SHA1 Message Date
Typosaurus
feb1963aad ticket #93 attempt 1 2026-07-19 21:08:41 +00:00
250 changed files with 1243 additions and 12205 deletions

View File

@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After writing a test module, validate it ONLY by a clean import (`python -c "import tests..."` or `python -m py_compile`).
## Mode
Default to **REPORT** mode: record coverage gaps and pattern violations, do NOT write files. Apply **FIX** mode only when the invocation explicitly asks you to fix; then write the missing integration test following the required patterns. **HARD GUARDRAIL: write tests but NEVER launch the suite yourself, not the full suite and not a single file - the serial single-process suite cannot run concurrently with other agents, so the orchestrating session runs `make test` (all tests) after your work.** Validate only by a clean import of the new test module. Never perform any git write operation.
Default to **REPORT** mode: record coverage gaps and pattern violations, do NOT write files. Apply **FIX** mode only when the invocation explicitly asks you to fix; then write the missing integration test following the required patterns. **HARD GUARDRAIL: write tests but NEVER run the suite, not the full suite and not a single file.** Validate only by a clean import of the new test module. Never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.

View File

@ -1,5 +1,5 @@
---
description: Run DevPlace tests. Run a tier, a file, or a single test with the correct flags. Subagents never run tests themselves (the serial suite cannot run concurrently); the orchestrating session always runs the full suite as the final validation of every change.
description: Run DevPlace tests - the sanctioned explicit-ask path. Run a tier, a file, or a single test with the correct flags. The agents never run tests themselves; this command is how you ask.
argument-hint: [unit|api|e2e|all|<path::test_name>]
allowed-tools: Bash(make test*), Bash(python -m pytest *), Read
---
@ -12,6 +12,6 @@ Mapping:
- `all` or empty -> `make test`
- a path like `tests/api/posts/create.py::test_x` -> `python -m pytest <that> -v --tb=line -x`
Tests run serially on port 10501 with a tempfile SQLite DB and `DEVPLACE_DISABLE_SERVICES=1`. Subagents and workflows never launch tests (the serial single-process suite cannot run concurrently); the orchestrating session runs the full suite (`make test`) as the mandatory final validation of every change.
Tests run serially on port 10501 with a tempfile SQLite DB and `DEVPLACE_DISABLE_SERVICES=1`. This command is the one sanctioned way to run them (the subagents and workflows never do).
Report results clearly. On a failure, show the relevant output, and if a browser (e2e) test failed, point me at the screenshot under `/tmp/devplace_test_screenshots/`. Never weaken a test to make it pass; if a test reveals a real bug, report it - do not edit the test.

File diff suppressed because one or more lines are too long

View File

@ -31,7 +31,7 @@ make locust-headless # Locust CLI mode for CI
The Makefile exports `PYTHONDONTWRITEBYTECODE=1` for every recipe, so no `.pyc` files or `__pycache__` directories are written by any `make` target. Keep it that way - do not add a target that re-enables bytecode writing. `make clean` removes any stray bytecode left from running Python outside make.
Preliminary validation: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance). These checks are gates on the way to the real validation, never a substitute for it: **every change ends with the full test suite (`make test` - all three tiers, every test) and it must pass.**
Validate code without running the suite: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance).
Single test: `python -m pytest tests/e2e/feed.py::test_name -v --tb=line -x`
@ -53,14 +53,10 @@ devplace attachments prune # remove orphan attachment records/files
devplace devii reset-quota <username> # reset one user's rolling 24h AI quota
devplace devii reset-quota --guests # reset every guest quota
devplace devii reset-quota --all # reset every quota (users and guests)
devplace gateway quota list # list AI gateway quota rules and current 24h spend
devplace gateway quota set --limit-usd N [--owner-kind K] [--owner-id ID] [--app-reference APP] [--label L] [--uid UID]
devplace gateway quota delete <uid> # delete a quota rule
devplace zips prune # delete expired zip archives + job rows
devplace zips clear # delete every zip archive + job row
devplace forks prune # delete expired completed fork job rows (forked projects persist)
devplace forks clear # delete every fork job row (forked projects persist)
devplace messaging prune-tickets # delete expired WebSocket auth tickets (ws_tickets)
devplace seo prune # delete expired SEO audit reports + job rows
devplace seo clear # delete every SEO audit report + job row
devplace seo-meta prune # delete expired SEO metadata job rows (generated metadata persists)
@ -137,7 +133,6 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
| `devplacepy/database/CLAUDE.md` | Dataset rules, indexing conventions, soft delete, tables, site settings |
| `devplacepy/utils/CLAUDE.md` | Notifications and gamification (XP/levels/badges/leaderboard) |
| `devplacepy/static/js/CLAUDE.md` | Custom web components, shared frontend utilities (Http/Poller/FloatingWindow/ScrollMemory) |
| `devplacepy/static/css/CLAUDE.md` | CSS system: design tokens (no literals, no `var()` fallbacks), file-scoped palettes, `--z-*` stacking bands, the closed breakpoint set, reduced motion |
| `devplacepy/templates/CLAUDE.md` | Modal system, CDN libraries, shared template partials |
| `tests/CLAUDE.md` | Detailed testing patterns and pitfalls |
@ -286,18 +281,7 @@ Fixture stack: `app_server` (session-scoped uvicorn subprocess on port 10501), `
Required patterns: every `page.goto(...)`/`page.wait_for_url(...)` MUST pass `wait_until="domcontentloaded"`; prefer `page.locator(...).wait_for(state="visible")` over `wait_for_selector`; scope ambiguous selectors (e.g. comment Delete is `.comment-action-btn:has-text('Delete')`). A test that flips a global `site_settings` value MUST restore it in `try/finally`. Full pitfalls/patterns catalogue is in `tests/CLAUDE.md`.
**Always run the full test suite (`make test` - unit, api, and e2e, every test) as the final validation of every change.** No tier may be skipped and no subset substituted for the whole. The clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks are preliminary gates before the suite, not replacements for it. Any failure is a real signal and blocks completion until fixed.
## Rigorous correctness verification (money, state machines, concurrency)
The persisted test suite (`tests/unit`/`api`/`e2e`) is example-based: it proves the specific inputs someone wrote down behave correctly. It is structurally blind to three classes of bug - a formula wrong at an input nobody tried, an invariant violated only after a long sequence of actions, and a race that only two nearly-simultaneous requests trigger. **Any feature that touches a spendable resource (coins, credits, quotas), a bounded state machine (levels, tiers, counters with a floor/ceiling), or a read-then-write mutation reachable from more than one request path applies this procedure in addition to, not instead of, the normal persisted tests.** A simple CRUD toggle doesn't need it; an economy, inventory, ledger, or scoring feature does. This was built out fully for the Code Farm economy rebalance (see `devplacepy/services/game/CLAUDE.md`, "Every purchase/upgrade is atomic..." - the worked example, including every bug it actually caught) and is the standing procedure for anything shaped like it going forward.
Four escalating layers, run in order, as disposable Python scripts that call the real functions directly against a temp DB (`DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR` pointed at a scratch file) - not pytest files, unless the user separately asks for persisted tests too:
1. **Property/invariant checks on every pure function, across the full input domain.** For each formula (a reward, a cost curve, a score, a discount), assert the mathematical property it must have - monotonicity, bounds, non-negativity, idempotency, additivity over a partition of its domain - by iterating the real range (hundreds to thousands of values), not a handful of hand-picked spot checks. A monotonicity bug at input 347 is invisible if only 0, 1, and 10 are tried.
2. **Stateful fuzzing.** Build N independent entities, fire a long randomized sequence of every mutating action across all of them against a live temp DB, and re-check invariants after every single action: balances never negative, counters that should only increase never decrease, levels/tiers never exceed their declared bounds. Catch the domain's expected exception type and continue - the goal is catching invariant violations and *unexpected* exceptions, not asserting every action succeeds. This proves safety (nothing bad happens) but not liveness (legitimate actions actually succeed) - a bug that wrongly blocks a valid action just raises a well-formed expected exception and sails through a pure fuzz test undetected. Layer 3 exists because of exactly this gap.
3. **Concurrency: prove races are closed, never infer it from luck.** Any read-then-write mutation reachable from more than one worker process (`uvicorn --workers N`) is a TOCTOU race until proven otherwise. Test with **real separate OS processes**, not threads in one process - `dataset` gives each thread its own pooled connection, and enough threads exhausts that pool and produces `database is locked` noise that is a test-harness artifact, not a finding about the application. Set up genuinely fresh, production-representative state before racing - **never pre-seed or zero a column that the real code path leaves unset/NULL.** This was the single most expensive mistake made building this procedure: a first race-safety pass "passed" only because its own setup script had artificially pre-zeroed columns that a real fresh row leaves as SQL `NULL` - silently hiding the exact bug the fix was supposed to prevent (`NULL = 0` evaluates to `NULL`, not true, in a SQL `WHERE` clause; any column not written in the row's original `INSERT` needs `COALESCE(column, 0)` in every later precondition and every arithmetic `SET`, not a bare comparison). Fire many concurrent attempts at the same resource, assert the exact right number succeed, and verify the final state matches the hand-computed expected total exactly (currency spent, levels advanced) - not just "the others were blocked." Fix a real race with a single atomic conditional SQL statement at the exact chokepoint (`UPDATE ... SET ... WHERE <precondition>`, checked via the driver's real `rowcount` - `dataset`'s wrapped `db.query()` does not expose it, use `db.executable.execute(sqlalchemy.text(...), params).rowcount` inside `with db:`), not a client-side lock or an optimistic-locking library this codebase doesn't otherwise use.
4. **Static analysis beyond `py_compile`.** A clean `python -m py_compile` and a clean `from devplacepy.main import app` only prove syntax and module-load order - neither catches a missing import inside a function body, which is a runtime `NameError` invisible until that exact line executes. Run `pyflakes`/`ruff check` on every touched file before calling a change done; it is nearly free and catches an entire class of bug that layers 1-3 can each individually miss if they don't happen to exercise the broken line.
**Never run tests unless the user explicitly asks for it.** Not the full suite, not a single file. Validate with a clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks instead.
## Feature workflow
@ -316,7 +300,7 @@ A new public read almost always needs all four. The cardinal failure mode is cha
4. **View layer.** Extend `base.html`; page CSS in `{% block extra_head %}`, page JS in `{% block extra_js %}`. Reuse partials and template globals. JS is ES6 modules, one class per file, instantiated on `app`.
5. **Agent + docs layer (the most-forgotten step - do not skip).** `services/devii/actions/catalog.py` - add an `Action` tool if a user could ask Devii to do this (`requires_auth=False` for public reads). `docs_api.py` - every public/auth endpoint gets an `endpoint()` entry with params and a `sample_response`. `routers/docs/pages.py` `DOCS_PAGES` for prose pages. SEO (`seo.py`) - `base_seo_context` and any JSON-LD for a new public page; add to `routers/seo.py` sitemap if indexable.
6. **Document.** `README.md` (product-facing, routes/config/dependencies/user-visible features), the relevant nested `CLAUDE.md` (any new mechanic, helper, pitfall, or cross-layer wiring), this root `CLAUDE.md` only when a NEW architectural rule or workflow step is introduced (not per feature).
7. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen), and run `pyflakes`/`ruff check` on every touched file. **If the feature touches a spendable resource, a bounded state machine, or a read-then-write mutation reachable from more than one request path, also run the four-layer procedure in "Rigorous correctness verification" above** - it is the default for that shape of feature, not an optional extra. **Finish by running the full persisted test suite (`make test` - every tier, every test) and fixing any failure before the change is done.** Write new tests in the matching tier/path, following the required patterns above.
7. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen). **Never run the test suite unless the user explicitly asks.** Write new tests in the matching tier/path when asked, following the required patterns above.
Failures at any implementation step block the workflow - never skip a failed step.
@ -331,4 +315,3 @@ Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn
- **Shared DB and files = same as dev.** The app container bind-mounts the host project root (`.:/app`) and runs as `${DEVPLACE_UID}:${DEVPLACE_GID}` (default `1000`), so it reads/writes the same `data/devplace.db`, `data/uploads/`, `data/devii_*.db`, `data/keys/` (VAPID), and `data/locks/devplace-services.lock` as `make dev`. No `DEVPLACE_DATABASE_URL` override - `config.py` resolves an absolute path under the project's `data/` dir. WAL + the `flock` on `devplace-services.lock` make concurrent dev/prod safe and keep a single background-services owner. SQLite is local-file, so prod and dev must be the **same host**.
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
- **nginx parity rules** (`nginx/nginx.conf.template`, rendered by `start.sh` via `envsubst` with an allow-list that preserves `$http_upgrade`): `/static/uploads/` must re-apply `nosniff` + a `Content-Disposition` via the `map $uri $upload_disposition` block (`inline` for safe image/video/audio extensions, `attachment` otherwise), mirroring `UploadStaticFiles.INLINE_MEDIA_EXTENSIONS` - an XSS control nginx would otherwise bypass, and the inline branch is what lets video play in production; `/devii/ws` needs the `map $http_upgrade $connection_upgrade` block and `Upgrade`/`Connection` headers or the Devii terminal cannot connect (every new WebSocket route needs its own nginx upgrade location - the catch-all `location /` strips upgrade headers); `client_max_body_size` comes from `NGINX_MAX_BODY_SIZE` (default `50m`) and must be `>= max_upload_size_mb` or uploads 413. nginx serves `devplacepy/static` via a read-only bind mount, so assets stay current without an image rebuild.
- **Healthcheck start period** (`start_period: 120s` in `docker-compose.yml`, `--start-period=120s` in `Dockerfile`): full startup takes ~110s (DB init, services, uvicorn workers). The start period must stay above that. Bump both files if startup grows.

View File

@ -33,7 +33,7 @@ EXPOSE 10500
ENV DEVPLACE_WEB_WORKERS=2
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s \
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
CMD curl -f http://localhost:10500/ || exit 1
CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"]

View File

@ -72,7 +72,7 @@ devplacepy/
| `/uploads` | File upload endpoints: `POST /uploads/upload` (multipart), `POST /uploads/upload-url` (from URL); served at `/static/uploads/` |
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image, video and audio embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants (the `message` WS frame carries an additive `ai_processed` flag when a pending correction/modification was applied). An opened conversation loads its 500 most recent messages; older history is retained in the database. `GET /messages/conversations` returns the conversation list as JSON for a live client refresh; the `POST /messages/send` form remains as a no-JavaScript fallback and now accepts an attachment-only, empty-content message. `POST /messages/ws-ticket` exchanges a session/API-key auth to a short-lived (30s), single-use ticket a browser WebSocket can carry as `?ticket=...` on the handshake, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header - this is what makes off-session embedding of the chat possible. CLI: `devplace messaging prune-tickets` removes expired tickets |
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image and YouTube embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants. An opened conversation loads its 500 most recent messages; older history is retained in the database. The `POST /messages/send` form remains as a no-JavaScript fallback |
| `/votes` | Upvote/downvote on posts, comments, projects |
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
@ -125,21 +125,14 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill
- **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. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop. Refactoring costs a **dynamic coin fee** that grows with your prestige and current wealth, so you must farm well past level 10 to afford each one - and 10% of what remains after the fee (more with the Golden Parachute Legacy upgrade, up to 35%) carries over into the new run.
- **Community treasury and weekly grant.** Every refactor fee flows into a shared treasury. Active low-balance, low-prestige farms (at least five harvests this week, under 10,000 coins, at most prestige 5) can claim a capped grant from it once per week - a direct wealth transfer from the farms refactoring at the top to the farms building at the bottom.
- **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), **Branch Protection** (longer steal grace and a smaller steal cut), and **Golden Parachute** (a larger refactor coin carry-over). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off.
- **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 protection window passes - the owner gets that grace period (longer if they invested in Branch Protection or a Defense building) to harvest it first. A successful steal pays the thief a fraction of 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**. Raiding a farm with 10x your own coins grants a 24-hour **Underdog** boost (+25% coin gain) and the **David vs Goliath** badge. Stealing pays coins only, so the harvest-based leaderboards stay earned by real farming.
- **Market Saturation.** The last 48 hours of league-wide harvests of each crop are tracked; when a crop is over-farmed its payout drops in steps (down to 40%), while the four starter crops get a relief buff (up to +15%) while the market is saturated - printing one crop nonstop is throttled, diversity is rewarded. The shop shows a live "Saturated" / "Boosted" label per crop.
- **Infrastructure.** Permanent, expensive, prestige-gated buildings and coin sinks: **Private Registry** (faster Rust/Compiler/Kernel builds), **Canary Deployments** (a chance to double or only refund a harvest), and **Observability Suite** (raises the minimum you keep when raided).
- **Defense.** An upgradeable building that reduces raid losses and adds steal grace - but costs an ongoing daily coin upkeep (proportional to your coin balance, so it scales with wealth); if unpaid, the tier decays automatically.
- **Cosmetics.** Purely cosmetic titles and plot skins, bought with coins - zero gameplay effect, pure status. An equipped title shows next to your name on the leaderboard.
- **Mastery (endgame beyond prestige).** From prestige 50 onward, every 10 more prestige earns a permanent Mastery point (spendable, and the milestone itself never re-locks). Mastery upgrades open new gameplay instead of bigger numbers: **Continuous Delivery** (auto-replant after harvest), **Farm Analytics** (lifetime stats on your HUD), and **Legacy Contracts** (a weekly long-term contract slot paying Stars and a temporary coin boost). Reaching Mastery also unlocks three new high-tier crop families (Distributed System, ML Pipeline, Security Fortress - the last one immune to raids).
- **Leaderboards.** Several boards, selectable from the game page: **Overall score** (a composite weighing refactor/prestige count, XP, lifetime harvests, coins, CI tier, plots, perks, and streak), **Prestige**, **Harvests this week**, **Raid efficiency** (average coins per successful raid), **Fastest to Kernel** (time since your last refactor), **Fair play** (rewards recent activity over hoarding), and (when running) the current **Era** board.
- **Eras (admin-managed seasons).** Administrators can start a Era at `/admin/game`: every farm's *visible* Era coins/harvests counters reset to zero, but real coin balances, prestige, Stars, Legacy, and Mastery are never touched. Ending an Era ranks farms by Era score (which gives prestige only partial weight, so veterans keep an edge without it being insurmountable), awards Stars to the top 10, and permanently records the results.
- **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`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_claim_grant`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`). 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

View File

@ -1,84 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_game_market_prune(args):
from devplacepy.services.game import store
removed = store.prune_ticks()
_audit_cli(
"cli.game.market.prune",
f"CLI pruned {removed} stale Code Farm market tick(s)",
metadata={"count": removed},
)
print(f"Pruned {removed} stale market tick bucket(s)")
def cmd_game_era_status(args):
from devplacepy.services.game import store
era = store.active_era()
if not era:
print("No Era is currently running.")
return
print(f"Era {era['era_number']}: {era['name']}")
print(f"Started: {era['started_at']}")
print(f"Scheduled end: {era['ends_at']}")
def cmd_game_era_start(args):
from devplacepy.services.game import GameError, store
try:
era = store.start_era(args.name, args.duration_days)
except GameError as exc:
print(f"Error: {exc}")
return
_audit_cli(
"cli.game.era.start",
f"CLI started Code Farm Era {era['era_number']}: {era['name']}",
metadata={"era_number": era["era_number"], "name": era["name"]},
)
print(f"Started Era {era['era_number']}: {era['name']}")
def cmd_game_era_end(args):
from devplacepy.services.game import GameError, store
try:
result = store.end_era()
except GameError as exc:
print(f"Error: {exc}")
return
_audit_cli(
"cli.game.era.end",
f"CLI ended Code Farm Era {result['era_number']}",
metadata=result,
)
print(f"Ended Era {result['era_number']} ({result['participants']} participant(s) ranked)")
def register_game(subparsers):
game = subparsers.add_parser("game", help="Code Farm management")
game_sub = game.add_subparsers(title="action", dest="action")
market = game_sub.add_parser("market", help="Code Farm market saturation data")
market_sub = market.add_subparsers(title="market_action", dest="market_action")
market_prune = market_sub.add_parser(
"prune", help="Delete market tick buckets older than the tracking window"
)
market_prune.set_defaults(func=cmd_game_market_prune)
era = game_sub.add_parser("era", help="Code Farm Era management")
era_sub = era.add_subparsers(title="era_action", dest="era_action")
era_status = era_sub.add_parser("status", help="Show the current Era status")
era_status.set_defaults(func=cmd_game_era_status)
era_start = era_sub.add_parser("start", help="Start a new Era")
era_start.add_argument("name", help="Era name")
era_start.add_argument(
"--days", dest="duration_days", type=int, default=28, help="Planned Era length in days"
)
era_start.set_defaults(func=cmd_game_era_start)
era_end = era_sub.add_parser("end", help="End the currently running Era")
era_end.set_defaults(func=cmd_game_era_end)

View File

@ -1,103 +0,0 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.cli._shared import _audit_cli
def cmd_gateway_quota_list(args):
from devplacepy.services.openai_gateway import quota
rules = quota.quota_rule_store.list()
if not rules:
print("No quota rules. Every caller is capped by the global defaults on /admin/services/openai.")
return
for rule in rules:
spent = quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"])
scope = ", ".join(
f"{key}={rule[key]}" for key in ("owner_kind", "owner_id", "app_reference") if rule[key]
) or "(no dimensions - invalid)"
limit = "unlimited" if rule["limit_usd"] == 0 else f"${rule['limit_usd']:.2f}/24h"
active = "active" if rule["is_active"] else "inactive"
label = f" - {rule['label']}" if rule["label"] else ""
print(f"{rule['uid']} [{scope}] {limit} spent=${spent:.4f} {active}{label}")
def cmd_gateway_quota_set(args):
from pydantic import ValidationError
from devplacepy.services.openai_gateway import quota
try:
payload = quota.QuotaRuleIn(
owner_kind=args.owner_kind,
owner_id=args.owner_id,
app_reference=args.app_reference,
limit_usd=args.limit_usd,
is_active=not args.inactive,
label=args.label or "",
)
except ValidationError as exc:
print(f"Invalid rule: {exc.errors()[0].get('msg', exc)}")
sys.exit(1)
saved = quota.quota_rule_store.set(payload, uid=args.uid, created_by="cli")
_audit_cli(
"gateway.quota_rule.update",
f"CLI saved gateway quota rule {saved['uid']}",
metadata={
"owner_kind": saved["owner_kind"],
"owner_id": saved["owner_id"],
"app_reference": saved["app_reference"],
"limit_usd": saved["limit_usd"],
},
target_type="gateway_quota_rule",
target_uid=saved["uid"],
)
print(f"Saved quota rule {saved['uid']}")
def cmd_gateway_quota_delete(args):
from devplacepy.services.openai_gateway import quota
if not quota.quota_rule_store.remove(args.uid):
print(f"Quota rule '{args.uid}' not found")
sys.exit(1)
_audit_cli(
"gateway.quota_rule.delete",
f"CLI deleted gateway quota rule {args.uid}",
target_type="gateway_quota_rule",
target_uid=args.uid,
)
print(f"Deleted quota rule {args.uid}")
def register_gateway(subparsers):
gateway = subparsers.add_parser("gateway", help="AI gateway management")
gateway_sub = gateway.add_subparsers(title="action", dest="action")
quota = gateway_sub.add_parser("quota", help="Manage rolling-24h AI gateway quota rules")
quota_sub = quota.add_subparsers(title="sub-action", dest="sub_action")
quota_list = quota_sub.add_parser("list", help="List all quota rules and their current 24h spend")
quota_list.set_defaults(func=cmd_gateway_quota_list)
quota_set = quota_sub.add_parser(
"set", help="Create or update a quota rule (scope by role/user/app, any combination)"
)
quota_set.add_argument("--uid", help="Existing rule uid to update; omit to create a new rule")
quota_set.add_argument(
"--owner-kind",
choices=("internal", "key", "user", "admin", "anonymous"),
help="Role to scope by. Omit for any role",
)
quota_set.add_argument("--owner-id", help="Specific user uid to scope by. Omit for any caller")
quota_set.add_argument("--app-reference", help="App label to scope by. Omit for any app")
quota_set.add_argument(
"--limit-usd", type=float, required=True, help="Rolling 24h USD cap (0 = unlimited)"
)
quota_set.add_argument("--label", help="Optional admin-facing note")
quota_set.add_argument("--inactive", action="store_true", help="Create the rule disabled")
quota_set.set_defaults(func=cmd_gateway_quota_set)
quota_delete = quota_sub.add_parser("delete", help="Delete a quota rule by uid")
quota_delete.add_argument("uid", help="Quota rule uid")
quota_delete.set_defaults(func=cmd_gateway_quota_delete)

View File

@ -7,12 +7,12 @@ from devplacepy.cli._shared import _audit_cli
def _remove_zip_artifacts(job):
import shutil
from pathlib import Path
from devplacepy.config import ZIP_STAGING_DIR
from devplacepy.services.jobs.zip_service import STAGING_DIR
local_path = (job.get("result") or {}).get("local_path")
if local_path:
Path(local_path).unlink(missing_ok=True)
shutil.rmtree(ZIP_STAGING_DIR / job["uid"], ignore_errors=True)
shutil.rmtree(STAGING_DIR / job["uid"], ignore_errors=True)
def cmd_zips_prune(args):
@ -251,6 +251,7 @@ def cmd_isslop_clear(args):
def cmd_isslop_analyze(args):
import asyncio
import json
from devplacepy.config import ISSLOP_WORKSPACES_DIR, ensure_data_dirs
from devplacepy.database import INTERNAL_GATEWAY_URL, internal_gateway_key

View File

@ -12,9 +12,6 @@ from devplacepy.cli.jobs import register_jobs
from devplacepy.cli.backups import register_backups
from devplacepy.cli.containers import register_containers
from devplacepy.cli.migrate import register_migrate
from devplacepy.cli.game import register_game
from devplacepy.cli.gateway import register_gateway
from devplacepy.cli.messaging import register_messaging
def build_parser():
@ -31,9 +28,6 @@ def build_parser():
register_backups(sub)
register_containers(sub)
register_migrate(sub)
register_game(sub)
register_gateway(sub)
register_messaging(sub)
return parser

View File

@ -1,29 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_messaging_prune_tickets(args):
from datetime import datetime, timezone
from devplacepy.database import get_table
now = datetime.now(timezone.utc).isoformat()
tickets = get_table("ws_tickets")
expired = list(tickets.find(expires_at={"<": now}))
for ticket in expired:
tickets.delete(uid=ticket["uid"])
_audit_cli(
"cli.messaging.prune_tickets",
f"CLI pruned {len(expired)} expired WS tickets",
metadata={"count": len(expired)},
)
print(f"Pruned {len(expired)} expired WS ticket(s)")
def register_messaging(subparsers):
messaging = subparsers.add_parser("messaging", help="Messaging WS ticket management")
messaging_sub = messaging.add_subparsers(title="action", dest="action")
messaging_prune_tickets = messaging_sub.add_parser(
"prune-tickets", help="Delete expired WebSocket auth tickets"
)
messaging_prune_tickets.set_defaults(func=cmd_messaging_prune_tickets)

View File

@ -18,7 +18,6 @@ NOTIFICATION_TYPES = [
{"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"},
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
]

View File

@ -358,21 +358,6 @@ def init_db():
_index(
db, "issue_comment_authors", "idx_issue_comment_authors_number", ["gitea_number"]
)
ws_tickets = get_table("ws_tickets")
for column, example in (
("uid", ""),
("token", ""),
("user_uid", ""),
("created_at", ""),
("expires_at", ""),
("used_at", ""),
):
if not ws_tickets.has_column(column):
ws_tickets.create_column_by_example(column, example)
_index(db, "ws_tickets", "idx_ws_tickets_token", ["token"], unique=True)
_index(db, "ws_tickets", "idx_ws_tickets_expires", ["expires_at"])
migrate_bug_tables_to_issue_tables()
_index(db, "service_state", "idx_service_state_name", ["name"])
if "devii_conversations" in db.tables:
@ -486,26 +471,16 @@ def init_db():
_index(db, "jobs", "idx_jobs_expires", ["expires_at"])
_index(db, "project_forks", "idx_project_forks_source", ["source_project_uid"])
_index(db, "project_forks", "idx_project_forks_forked", ["forked_project_uid"])
instances = get_table("instances")
for column, example in (
("uid", ""),
("project_uid", ""),
("slug", ""),
("name", ""),
("status", ""),
("desired_state", ""),
("container_id", ""),
("ingress_slug", ""),
("ingress_port", 0),
("ports_json", ""),
("container_gateway", ""),
("run_as_uid", ""),
("boot_language", "none"),
("boot_script", ""),
("start_on_boot", 0),
):
if not instances.has_column(column):
instances.create_column_by_example(column, example)
if "instances" in db.tables:
instances = get_table("instances")
for column, example in (
("run_as_uid", ""),
("boot_language", "none"),
("boot_script", ""),
("start_on_boot", 0),
):
if not instances.has_column(column):
instances.create_column_by_example(column, example)
_index(db, "instances", "idx_instances_project", ["project_uid"])
_index(db, "instances", "idx_instances_slug", ["slug"])
@ -543,10 +518,6 @@ def init_db():
from devplacepy.services.openai_gateway import routing as gateway_routing
gateway_routing.ensure_tables()
from devplacepy.services.openai_gateway import quota as gateway_quota
gateway_quota.ensure_tables()
_index(db, "audit_log", "idx_audit_created_at", ["created_at"])
_index(db, "audit_log", "idx_audit_event_key", ["event_key"])
_index(db, "audit_log", "idx_audit_category", ["category"])
@ -1043,31 +1014,6 @@ def init_db():
("legacy_speed", 0),
("legacy_plots", 0),
("legacy_defense", 0),
("legacy_carryover", 0),
("last_grant_week", ""),
("prestiged_at", ""),
("mastery_points", 0),
("mastery_points_earned_total", 0),
("mastery_autoreplant", 0),
("mastery_analytics", 0),
("mastery_contracts", 0),
("lifetime_coins_earned", 0),
("lifetime_harvests", 0),
("infra_registry", 0),
("infra_canary", 0),
("infra_observability", 0),
("defense_level", 0),
("defense_last_upkeep_at", ""),
("active_title", ""),
("underdog_boost_until", ""),
("contract_boost_until", ""),
("harvests_week", 0),
("harvests_week_start", ""),
("last_kernel_harvest_prestige", 0),
("time_to_kernel_seconds", 0),
("era_coins", 0),
("era_harvests", 0),
("era_joined_at", ""),
("created_at", ""),
("updated_at", ""),
):
@ -1099,7 +1045,6 @@ def init_db():
("farm_uid", ""),
("user_uid", ""),
("day", ""),
("scope", "daily"),
("slot_index", 0),
("kind", ""),
("label", ""),
@ -1107,17 +1052,13 @@ def init_db():
("progress", 0),
("reward_coins", 0),
("reward_xp", 0),
("reward_stars", 0),
("claimed", 0),
("created_at", ""),
("updated_at", ""),
):
if not game_quests.has_column(column):
game_quests.create_column_by_example(column, example)
with db:
db.query("UPDATE game_quests SET scope = 'daily' WHERE scope IS NULL OR scope = ''")
_index(db, "game_quests", "idx_game_quests_farm_day", ["farm_uid", "day"])
_index(db, "game_quests", "idx_game_quests_farm_day_scope", ["farm_uid", "day", "scope"])
game_plots = get_table("game_plots")
for column, example in (
@ -1136,83 +1077,6 @@ def init_db():
game_plots.create_column_by_example(column, example)
_index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"])
game_market_ticks = get_table("game_market_ticks")
for column, example in (
("uid", ""),
("crop_key", ""),
("hour_bucket", ""),
("harvests", 0),
("updated_at", ""),
):
if not game_market_ticks.has_column(column):
game_market_ticks.create_column_by_example(column, example)
_index(
db,
"game_market_ticks",
"idx_game_market_ticks_bucket",
["crop_key", "hour_bucket"],
unique=True,
)
game_cosmetics = get_table("game_cosmetics")
for column, example in (
("uid", ""),
("user_uid", ""),
("cosmetic_key", ""),
("purchased_at", ""),
("created_at", ""),
):
if not game_cosmetics.has_column(column):
game_cosmetics.create_column_by_example(column, example)
_index(
db,
"game_cosmetics",
"idx_game_cosmetics_owner",
["user_uid", "cosmetic_key"],
unique=True,
)
game_treasury = get_table("game_treasury")
for column, example in (
("uid", ""),
("balance", 0),
("collected_total", 0),
("granted_total", 0),
("updated_at", ""),
):
if not game_treasury.has_column(column):
game_treasury.create_column_by_example(column, example)
game_eras = get_table("game_eras")
for column, example in (
("uid", ""),
("era_number", 0),
("name", ""),
("started_at", ""),
("ends_at", ""),
("active", 0),
("created_at", ""),
):
if not game_eras.has_column(column):
game_eras.create_column_by_example(column, example)
_index(db, "game_eras", "idx_game_eras_active", ["active"])
game_era_results = get_table("game_era_results")
for column, example in (
("uid", ""),
("era_number", 0),
("user_uid", ""),
("rank", 0),
("era_score", 0),
("era_coins_final", 0),
("reward_stars", 0),
("reward_cosmetic_key", ""),
("created_at", ""),
):
if not game_era_results.has_column(column):
game_era_results.create_column_by_example(column, example)
_index(db, "game_era_results", "idx_game_era_results_era", ["era_number", "rank"])
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
_index(
db,

View File

@ -613,53 +613,6 @@ four ways to sign requests.
auth="admin",
destructive=True,
),
endpoint(
id="admin-gateway-quota-rules",
method="GET",
path="/admin/gateway/quota-rules",
title="List AI gateway quota rules",
summary=(
"List every rolling-24h USD quota rule on /openai/v1/*, each scoped by any "
"combination of role, specific user uid, and app_reference label, plus the "
"global per-role default caps that apply when no rule matches."
),
auth="admin",
interactive=True,
),
endpoint(
id="admin-gateway-quota-rule-set",
method="POST",
path="/admin/gateway/quota-rules",
title="Create or update an AI gateway quota rule",
summary=(
"Caps rolling-24h USD spend on /openai/v1/*. At least one of owner_kind, "
"owner_id, app_reference must be set; leaving a dimension blank makes it a "
"wildcard, and the most specific active match wins over other rules and over "
"the global default. Pass uid to update an existing rule."
),
auth="admin",
params=[
field("uid", "json", "string", False, "", "Existing rule uid to update; omit to create a new rule."),
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = any role."),
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = any caller of the matched role."),
field("app_reference", "json", "string", False, "devplace-bots-v-1-0-0", "App label (the X-App-Reference header). Blank = any app."),
field("limit_usd", "json", "number", True, "2.5", "Rolling 24h USD cap. 0 = unlimited."),
field("is_active", "json", "boolean", False, "true", "Whether the rule is enforced."),
field("label", "json", "string", False, "", "Optional admin-facing note."),
],
),
endpoint(
id="admin-gateway-quota-rule-delete",
method="DELETE",
path="/admin/gateway/quota-rules/{uid}",
title="Delete an AI gateway quota rule",
summary="Delete a quota rule; callers it covered fall back to the next most specific rule or the global default.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "RULE_UID", "Quota rule uid."),
],
),
endpoint(
id="admin-bots-monitor",
method="GET",
@ -877,37 +830,5 @@ four ways to sign requests.
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-game",
method="GET",
path="/admin/game",
title="Code Farm Era management",
summary="View the current Code Farm Era status.",
auth="admin",
sample_response={"era_active": False, "era_name": ""},
),
endpoint(
id="admin-game-era-start",
method="POST",
path="/admin/game/era/start",
title="Start an Era",
summary="Start a new Code Farm Era: resets every farm's visible Era coins/harvests counters to zero. Real coins, prestige, stars, Legacy, and Mastery are never touched.",
auth="admin",
params=[
field("name", "form", "string", True, "Genesis", "Era name."),
field("duration_days", "form", "int", False, "28", "Planned Era length in days."),
],
sample_response={"ok": True, "redirect": "/admin/game"},
),
endpoint(
id="admin-game-era-end",
method="POST",
path="/admin/game/era/end",
title="End the running Era",
summary="End the current Era: ranks every participating farm by Era score, awards Stars to the top 10 (and an Era-exclusive cosmetic when available), and permanently records the results.",
auth="admin",
destructive=True,
sample_response={"ok": True, "redirect": "/admin/game"},
),
],
}

View File

@ -12,9 +12,6 @@ The Code Farm is a cooperative idle game. Each member owns a farm of plots, plan
projects that build over real time, harvests them for coins and XP, upgrades their CI tier for
faster builds, and waters other members' growing builds to speed them up and earn coins.
Refactoring (prestige) costs a dynamic coin fee that grows with prestige and current wealth;
the fees fill a community treasury from which active low-balance farms can claim a weekly grant.
All endpoints negotiate HTML or JSON. The action endpoints return the full farm state so a
client can refresh without a second request.
""",
@ -53,10 +50,9 @@ client can refresh without a second request.
method="GET",
path="/game/leaderboard",
title="Farm leaderboard",
summary="Top farmers on a chosen board: score (default), prestige, harvests (this week), raids (avg coins per successful raid, min 3 raids), time_to_kernel, fair_play, or era (current Era only, empty when none is running).",
summary="Top farmers ranked by level, XP, and harvests.",
auth="public",
params=[field("board", "query", "string", False, "score", "Leaderboard board key.")],
sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4, "score": 1000}]},
sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4}]},
),
endpoint(
id="game-view-farm",
@ -170,12 +166,9 @@ client can refresh without a second request.
method="POST",
path="/game/quests/claim",
title="Claim a quest",
summary="Claim a completed daily quest, or (with scope=weekly, requires the Legacy Contracts Mastery upgrade) the weekly contract, which pays Stars plus a temporary coin boost instead of coins/XP.",
summary="Claim a completed daily quest reward by its kind.",
auth="user",
params=[
field("quest", "form", "string", True, "harvest", "Quest kind."),
field("scope", "form", "string", False, "daily", "daily (default) or weekly."),
],
params=[field("quest", "form", "string", True, "harvest", "Quest kind.")],
sample_response={"ok": True, "farm": {"coins": 130}},
),
endpoint(
@ -183,78 +176,20 @@ 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 and earn Stars to spend on Legacy upgrades. Refactoring costs a coin fee that scales with prestige and current wealth (the farm state's refactor_cost); the fee funds the community treasury and a fraction of the remaining coins (10% base, more with the Golden Parachute Legacy upgrade) carries over. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.",
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, "coins": 6550}},
),
endpoint(
id="game-grant",
method="POST",
path="/game/grant",
title="Claim the community grant",
summary="Claim the weekly community grant, paid from the treasury filled by refactor fees. Eligible farms are active (5+ harvests this week), below 10000 coins, and at most prestige 5.",
auth="user",
sample_response={"ok": True, "farm": {"coins": 2550}},
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, defense, or carryover (Golden Parachute, raises the refactor coin carry-over).",
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}},
),
endpoint(
id="game-mastery",
method="POST",
path="/game/mastery",
title="Buy a Mastery upgrade",
summary="Spend Mastery points (earned every 10 prestige past 50) on a permanent Mastery upgrade: autoreplant, analytics, or contracts.",
auth="user",
params=[field("key", "form", "string", True, "autoreplant", "Mastery upgrade key.")],
sample_response={"ok": True, "farm": {"mastery_points": 0}},
),
endpoint(
id="game-infrastructure-buy",
method="POST",
path="/game/infrastructure/buy",
title="Buy Infrastructure",
summary="Buy a permanent, expensive, prestige-gated Infrastructure building: registry (faster rare crops), canary (double/refund harvest chance), or observability (raises the minimum you keep when raided).",
auth="user",
params=[field("key", "form", "string", True, "registry", "Infrastructure key.")],
sample_response={"ok": True, "farm": {"coins": 0}},
),
endpoint(
id="game-defense-upgrade",
method="POST",
path="/game/defense/upgrade",
title="Upgrade Defense",
summary="Buy the next Defense tier. Reduces raid losses and adds steal grace, but adds an ongoing daily coin upkeep (proportional to your coin balance) - if unpaid, the tier decays.",
auth="user",
sample_response={"ok": True, "farm": {"defense_level": 1}},
),
endpoint(
id="game-cosmetics-buy",
method="POST",
path="/game/cosmetics/buy",
title="Buy a cosmetic",
summary="Buy a purely cosmetic title or plot skin with coins. No gameplay effect.",
auth="user",
params=[field("key", "form", "string", True, "title_architect", "Cosmetic key.")],
sample_response={"ok": True, "farm": {"coins": 0}},
),
endpoint(
id="game-cosmetics-equip",
method="POST",
path="/game/cosmetics/equip",
title="Equip a title",
summary="Equip an owned title cosmetic so it shows next to your name on the leaderboard.",
auth="user",
params=[field("key", "form", "string", True, "title_architect", "An owned title cosmetic key.")],
sample_response={"ok": True, "farm": {"active_title": "title_architect"}},
),
],
}

View File

@ -134,7 +134,7 @@ for signing DevPlace's own requests.
path="/openai/v1/chat/completions",
title="Chat completions",
summary="OpenAI-compatible chat completion. Supports streaming.",
auth="user",
auth="public",
encoding="json",
params=[
field(
@ -174,7 +174,7 @@ for signing DevPlace's own requests.
path="/openai/v1/embeddings",
title="Embeddings",
summary="OpenAI-compatible text embeddings. Request model molodetz~embed.",
auth="user",
auth="public",
encoding="json",
params=[
field(
@ -214,7 +214,7 @@ for signing DevPlace's own requests.
path="/openai/v1/images/generations",
title="Image generation",
summary="OpenAI-compatible image generation. Request model molodetz-img-small.",
auth="user",
auth="public",
encoding="json",
params=[
field(
@ -262,7 +262,7 @@ for signing DevPlace's own requests.
path="/openai/v1/{path}",
title="Passthrough",
summary="Any other /v1 path is forwarded to the upstream as-is.",
auth="user",
auth="public",
interactive=False,
params=[
field(

View File

@ -57,9 +57,9 @@ four ways to sign requests.
"content",
"form",
"textarea",
False,
True,
"Hello there.",
"Body, 0-2000 characters. May be empty when at least one attachment is provided.",
"Body, 1-2000 characters.",
),
field(
"receiver_uid",
@ -71,38 +71,5 @@ four ways to sign requests.
),
],
),
endpoint(
id="messages-conversations",
method="GET",
path="/messages/conversations",
title="List conversations",
summary="Return the signed-in user's conversation list as JSON, for live refresh without a full page reload.",
auth="user",
interactive=False,
sample_response={
"conversations": [
{
"other_user": {"uid": "8f14e45f-...", "username": "alice_test"},
"last_message": "Hello there.",
"last_message_at": "2026-07-21T10:00:00+00:00",
"unread": True,
}
]
},
),
endpoint(
id="messages-ws-ticket",
method="POST",
path="/messages/ws-ticket",
title="Issue a WebSocket ticket",
summary="Exchange the caller's session/API-key auth for a short-lived, single-use ticket that a browser WebSocket handshake can carry as a query parameter (a native WebSocket cannot set custom auth headers).",
auth="user",
encoding="none",
interactive=False,
notes=[
"The ticket is valid for 30 seconds and can be redeemed exactly once, as `wss://.../messages/ws?ticket=<ticket>`.",
],
sample_response={"ticket": "3f9c2a...", "expires_in": 30},
),
],
}

View File

@ -320,7 +320,7 @@ four ways to sign requests.
),
field(
"description",
"json",
"body",
"string",
True,
"Great work on the release!",

View File

@ -12,6 +12,7 @@ from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
from starlette.middleware.gzip import GZipMiddleware
from devplacepy.config import (
STATIC_DIR,
STATIC_VERSION,
@ -609,6 +610,10 @@ async def response_timing(request: Request, call_next):
response.headers["X-Response-Time"] = f"{(time.perf_counter() - start) * 1000:.1f}ms"
return response
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)
_home_cache = TTLCache(ttl=int(os.environ.get("DEVPLACE_HOME_CACHE_TTL", "60")), max_size=4)

View File

@ -386,10 +386,9 @@ class ContainerScheduleForm(BaseModel):
class MessageForm(BaseModel):
content: str = Field(min_length=0, max_length=2000)
content: str = Field(min_length=1, max_length=2000)
receiver_uid: str = Field(min_length=1, max_length=36)
attachment_uids: list[str] = []
client_id: Optional[str] = Field(default=None, max_length=64)
class ProfileForm(BaseModel):
@ -592,25 +591,7 @@ class GamePerkForm(BaseModel):
class GameQuestForm(BaseModel):
quest: str = Field(min_length=1, max_length=40)
scope: str = Field(default="daily", min_length=1, max_length=10)
class GameLegacyForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameInfraForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameCosmeticForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameMasteryForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameEraStartForm(BaseModel):
name: str = Field(min_length=1, max_length=60)
duration_days: int = Field(default=28, ge=1, le=180)

View File

@ -463,15 +463,11 @@ def delete_node(project_uid: str, raw_path: str, deleted_by: str = "system") ->
if node is None:
raise ProjectFileError(f"'{path}' does not exist")
stamp = _now()
rows = _descendants(project_uid, path)
for row in rows:
for row in _descendants(project_uid, path):
_table().update(
{"uid": row["uid"], "deleted_at": stamp, "deleted_by": deleted_by},
["uid"],
)
for row in rows:
if row.get("is_binary"):
_unlink_blob(row)
def soft_delete_all_project_files(project_uid: str, deleted_by: str) -> None:
@ -582,11 +578,9 @@ def _export_node(row: dict, dest: Path) -> None:
if target.is_symlink():
target.unlink()
if row.get("is_binary") and row.get("stored_name") and row.get("directory"):
src = PROJECT_FILES_DIR / row["directory"] / row["stored_name"]
try:
shutil.copyfile(src, target)
except (FileNotFoundError, OSError):
logger.warning("Blob file missing during export: %s", src)
shutil.copyfile(
PROJECT_FILES_DIR / row["directory"] / row["stored_name"], target
)
else:
target.write_text(row.get("content") or "", encoding="utf-8")
@ -691,6 +685,7 @@ def import_from_dir(project_uid: str, src_dir, user: dict, *, skip_names=None) -
def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
_guard_writable(project_uid)
dest = Path(dest_dir).resolve()
dest.mkdir(parents=True, exist_ok=True)
if subpath:
@ -713,12 +708,9 @@ def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
if target.is_symlink() or target.is_file():
target.unlink()
if row.get("is_binary") and row.get("stored_name") and row.get("directory"):
src = PROJECT_FILES_DIR / row["directory"] / row["stored_name"]
try:
shutil.copyfile(src, target)
except (FileNotFoundError, OSError):
logger.warning("Blob file missing: %s", src)
continue
shutil.copyfile(
PROJECT_FILES_DIR / row["directory"] / row["stored_name"], target
)
else:
target.write_text(row.get("content") or "", encoding="utf-8")
written += 1

View File

@ -15,7 +15,7 @@ Prefixes are wired in `main.py`:
| `/projects` | projects/ package - `index.py` (listing/detail/create/delete plus owner-only visibility toggles `POST /{slug}/private` and `POST /{slug}/readonly`, and async `POST /{slug}/fork`), `files.py`, and the `containers/` subpackage (below). `main.py` mounts the whole `/projects` tree from this one package. See `routers/projects/CLAUDE.md` for the deep detail on this tree |
| `/projects/{slug}/files` | projects/files.py - per-project virtual filesystem (CRUD dirs/files, upload, inline edit, plus line-range ops: `lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append` for large text files) |
| `/profile` | profile/ package - `index.py` (page/search/followers/update/api-key; the **Posts** tab shows only the 10 most recent posts, while the sidebar `posts_count` stat always uses the real `count()` so it is never capped to the rendered 10), `customization.py`, `notifications.py`, `ai_correction.py` (`POST /{username}/ai-correction`, owner-or-admin AI content correction toggle + prompt), `ai_modifier.py` (`POST /{username}/ai-modifier`, owner-or-admin AI modifier toggle + prompt), `interactions.py` (`POST /{username}/interactions`, owner-or-admin Devii interactive widgets preference; admin default via `devii_interactions_default`), `telegram.py` (`POST /{username}/telegram`, owner-or-admin Telegram pairing-code request/unpair), `usage.py` (`_ai_quota`, re-exported from the package) |
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `GET /conversations` (flat JSON conversation list, same `get_conversations` helper as the page), `POST /send` (no-JS fallback, also accepts an attachment-only empty-content message), `POST /ws-ticket` (issues a 30s single-use WS auth ticket so a browser WebSocket can authenticate cross-context, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, and read receipts on top of the existing `messages` table - presence is NOT part of this WS (it is the generic `PresenceManager`/pub/sub mechanism, see `devplacepy/services/CLAUDE.md`). Both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live), broadcasts the FINAL corrected/modified content with an additive `ai_processed` frame flag (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `POST /send` (no-JS fallback), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, read receipts, and in-process presence on top of the existing `messages` table; both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live) and broadcasts the FINAL corrected/modified content (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
| `/notifications` | notifications.py |
| `/votes` | votes.py |
| `/reactions` | reactions.py - emoji reaction toggle on any target: `POST /reactions/{target_type}/{target_uid}` (optimistic `ReactionBar`) |
@ -25,7 +25,7 @@ Prefixes are wired in `main.py`:
| `/follow` | follow.py |
| (none) | relations.py - per-user block/mute relations: `POST /block/{username}`, `/block/unblock/{username}`, `/mute/{username}`, `/mute/unmute/{username}` (soft-deletable `user_relations` rows) |
| `/leaderboard` | leaderboard.py - `GET /leaderboard` XP/stars leaderboard page |
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`, `game`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md`. The `game` leaf (`/admin/game`) is the Code Farm Era admin page: `GET /admin/game` (status), `POST /admin/game/era/start` and `/era/end` - see `devplacepy/services/game/CLAUDE.md` |
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md` |
| `/admin/services` | admin/services.py |
| `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) |
| `/gists` | gists.py |
@ -43,7 +43,7 @@ Prefixes are wired in `main.py`:
| `/xmlrpc` | xmlrpc.py - reverse-proxies XML-RPC calls to the forking XML-RPC bridge (`services/xmlrpc/`, supervised by `XmlrpcService` on loopback `config.XMLRPC_PORT`), which generates one XML-RPC method per documented REST endpoint from `docs_api.API_GROUPS` (`posts.create`, `feed.list`, ...). One struct of named params per call; auth via in-band `api_key`, `X-API-KEY`/`Bearer` header, or `http://user:pass@host/xmlrpc` Basic. Full introspection + `system.multicall`; REST errors become XML-RPC faults. Exempt from the rate limiter (enforced on the forwarded internal hop). See `devplacepy/services/xmlrpc/CLAUDE.md` |
| `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree |
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,quests/claim}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}` |
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
@ -188,7 +188,7 @@ News articles have an internal detail page at `/news/{slug}` with full comment s
The home route (`main.py` `landing()`) never redirects - it renders `templates/landing.html` for everyone, branching on `user`:
- **Guests** get the marketing hero (`Join DevPlace Free` CTA + features grid).
- **Signed-in users** get a personalized dashboard hero (`.dashboard-welcome`): avatar, "Welcome back, {username}", quicklink buttons (`.dashboard-btn`, with `New Post` -> `/feed` as `.dashboard-btn-primary`, plus Code Farm/Projects/Gists), and a Posts/Stars/Level stat strip (`.dashboard-stats`, `user_post_count` + the user dict's `stars`/`level`). Styles live in the `.dashboard-*` classes in `static/css/landing.css`.
- **Signed-in users** get a personalized hero (`.landing-hero-user`): avatar, "Welcome back, {username}", a `Go to your feed` CTA, a Posts/Stars/Level stat strip (`user_post_count` + the user dict's `stars`/`level`), and quick links. Styles live in `.landing-hero-user`/`.landing-welcome`/`.landing-stats`/`.landing-quicklinks` in `static/css/landing.css`.
- Both states share the Latest Posts + Developer News + "Build With Us" sections. The **Build With Us** section is static HTML/CSS (`.landing-help-*` in `landing.css`): four cards linking to `/docs/index.html` (Documentation), `/swagger` + `/openapi.json` (API Reference), `/issues` (Contribute & Report), and Devii. The Devii card's `Launch Devii` button is a plain `<button data-devii-open>` that opens the globally mounted `DeviiTerminal` (`app.devii`) in place - no extra JS, route, schema, or Devii action; a secondary link points to `/devii/` for the full terminal page.
- Context adds `user`, `is_authenticated`, `user_post_count`; `LandingOut` carries `is_authenticated`/`user_post_count` for the JSON form. `GET /` is documented in `docs_api.py` (id `home`, mapped to `LandingOut`).

View File

@ -8,7 +8,6 @@ from devplacepy.routers.admin import (
backups,
bots,
containers,
game,
gateway_configs,
issues,
media,
@ -37,6 +36,5 @@ router.include_router(auditlog.router)
router.include_router(backups.router)
router.include_router(bots.router)
router.include_router(gateway_configs.router)
router.include_router(game.router)
router.include_router(services.router, prefix="/services")
router.include_router(containers.router, prefix="/containers")

View File

@ -1,97 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
from devplacepy.models import GameEraStartForm
from devplacepy.responses import respond, action_result, json_error, wants_json
from devplacepy.schemas import AdminGameOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.game import GameError, store
from devplacepy.utils import require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
def _era_context() -> dict:
era = store.active_era()
return {
"era_active": bool(era),
"era_name": era["name"] if era else "",
"era_number": int(era["era_number"]) if era else 0,
"era_started_at": era["started_at"] if era else "",
"era_ends_at": era["ends_at"] if era else "",
}
@router.get("/game", response_class=HTMLResponse)
async def admin_game(request: Request):
admin = require_admin(request)
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Code Farm - Admin",
description="Manage Code Farm Eras.",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Code Farm", "url": "/admin/game"},
],
schemas=[website_schema(base)],
)
return respond(
request,
"admin_game.html",
{
**seo_ctx,
"request": request,
"user": admin,
"admin_section": "game",
**_era_context(),
},
model=AdminGameOut,
)
@router.post("/game/era/start")
async def admin_game_era_start(request: Request, data: Annotated[GameEraStartForm, Form()]):
admin = require_admin(request)
try:
era = store.start_era(data.name, data.duration_days)
except GameError as exc:
logger.warning(f"Admin {admin['username']} failed to start Era: {exc}")
if wants_json(request):
return json_error(400, str(exc))
return action_result(request, "/admin/game")
audit.record(
request,
"admin.game.era_start",
user=admin,
metadata={"era_number": era["era_number"], "name": era["name"]},
summary=f"admin {admin['username']} started Era {era['name']}",
)
return action_result(request, "/admin/game")
@router.post("/game/era/end")
async def admin_game_era_end(request: Request):
admin = require_admin(request)
try:
result = store.end_era()
except GameError as exc:
logger.warning(f"Admin {admin['username']} failed to end Era: {exc}")
if wants_json(request):
return json_error(400, str(exc))
return action_result(request, "/admin/game")
audit.record(
request,
"admin.game.era_end",
user=admin,
metadata=result,
summary=f"admin {admin['username']} ended Era {result['era_number']}",
)
return action_result(request, "/admin/game")

View File

@ -9,7 +9,7 @@ from pydantic import ValidationError
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.manager import service_manager
from devplacepy.services.openai_gateway import quota, routing
from devplacepy.services.openai_gateway import routing
from devplacepy.templating import templates
from devplacepy.utils import require_admin
@ -182,92 +182,3 @@ async def delete_model(request: Request, source_model: str):
summary=f"admin {admin['username']} deleted gateway model route {source_model}",
)
return JSONResponse({"ok": True})
def _quota_defaults_summary() -> dict:
svc = service_manager.get_service("openai")
cfg = svc.get_config() if svc is not None else {}
return {
"user": cfg.get(quota.FIELD_DEFAULT_USER, 0.0),
"admin": cfg.get(quota.FIELD_DEFAULT_ADMIN, 0.0),
"guest": cfg.get(quota.FIELD_DEFAULT_GUEST, 0.0),
"internal": cfg.get(quota.FIELD_DEFAULT_INTERNAL, 0.0),
"key": cfg.get(quota.FIELD_DEFAULT_KEY, 0.0),
}
def _rule_label(rule: dict) -> str:
parts = []
if rule.get("owner_kind"):
parts.append(f"role={rule['owner_kind']}")
if rule.get("owner_id"):
parts.append(f"user={rule['owner_id']}")
if rule.get("app_reference"):
parts.append(f"app={rule['app_reference']}")
return ", ".join(parts) or rule.get("uid", "")
@router.get("/gateway/quota-rules")
async def list_quota_rules(request: Request):
require_admin(request)
rules = quota.quota_rule_store.list()
for rule in rules:
rule["spent_24h_usd"] = round(
quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"]), 6
)
return JSONResponse(
{
"rules": rules,
"count": len(rules),
"defaults": _quota_defaults_summary(),
}
)
@router.post("/gateway/quota-rules")
async def save_quota_rule(request: Request):
admin = require_admin(request)
body = await _payload(request)
uid = str(body.pop("uid", "") or "").strip() or None
try:
payload = quota.QuotaRuleIn(**body)
except ValidationError as exc:
return _validation_error(exc)
saved = quota.quota_rule_store.set(payload, uid=uid, created_by=admin["uid"])
audit.record(
request,
"gateway.quota_rule.update",
user=admin,
target_type="gateway_quota_rule",
target_uid=saved["uid"],
target_label=_rule_label(saved),
summary=f"admin {admin['username']} saved gateway quota rule ({_rule_label(saved)}) at ${saved['limit_usd']}/24h",
metadata={
"owner_kind": saved["owner_kind"],
"owner_id": saved["owner_id"],
"app_reference": saved["app_reference"],
"limit_usd": saved["limit_usd"],
"is_active": saved["is_active"],
},
)
return JSONResponse({"ok": True, "rule": saved})
@router.delete("/gateway/quota-rules/{uid}")
async def delete_quota_rule(request: Request, uid: str):
admin = require_admin(request)
existing = quota.quota_rule_store.get(uid)
label = _rule_label(existing.as_dict()) if existing else uid
existed = quota.quota_rule_store.remove(uid)
if not existed:
return JSONResponse({"ok": False, "error": "Quota rule not found"}, status_code=404)
audit.record(
request,
"gateway.quota_rule.delete",
user=admin,
target_type="gateway_quota_rule",
target_uid=uid,
target_label=label,
summary=f"admin {admin['username']} deleted gateway quota rule ({label})",
)
return JSONResponse({"ok": True})

View File

@ -88,8 +88,6 @@ async def steal_farm(
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
track_action(viewer["uid"], "harvest_stolen")
track_action(owner["uid"], "got_stolen_from")
if result.get("underdog_triggered"):
track_action(viewer["uid"], "underdog_raid")
create_notification(
owner["uid"],
"harvest_stolen",

View File

@ -6,10 +6,7 @@ from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.models import (
GameCosmeticForm,
GameInfraForm,
GameLegacyForm,
GameMasteryForm,
GamePerkForm,
GamePlantForm,
GameQuestForm,
@ -52,9 +49,9 @@ async def game_state(request: Request):
@router.get("/leaderboard")
async def game_leaderboard(request: Request, board: str = "score"):
async def game_leaderboard(request: Request):
get_current_user(request)
entries = store.leaderboard_for(board, 25)
entries = store.leaderboard(25)
return JSONResponse(
GameLeaderboardOut(entries=entries).model_dump(mode="json")
)
@ -122,12 +119,6 @@ async def game_daily(request: Request):
return await _respond_action(request, user, lambda: store.claim_daily(user))
@router.post("/grant")
async def game_claim_grant(request: Request):
user = require_user(request)
return await _respond_action(request, user, lambda: store.claim_grant(user))
@router.post("/perk")
async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]):
user = require_user(request)
@ -158,55 +149,5 @@ async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form
award_rewards(user["uid"], result.get("reward_xp", 0))
return await _respond_action(
request, user, lambda: store.claim_quest(user, data.quest, data.scope), reward
)
@router.post("/defense/upgrade")
async def game_upgrade_defense(request: Request):
user = require_user(request)
def reward(result):
track_action(user["uid"], "defense_upgraded")
return await _respond_action(request, user, lambda: store.upgrade_defense(user), reward)
@router.post("/infrastructure/buy")
async def game_buy_infrastructure(request: Request, data: Annotated[GameInfraForm, Form()]):
user = require_user(request)
def reward(result):
track_action(user["uid"], "infra_bought")
return await _respond_action(
request, user, lambda: store.buy_infrastructure(user, data.key), reward
)
@router.post("/mastery")
async def game_upgrade_mastery(request: Request, data: Annotated[GameMasteryForm, Form()]):
user = require_user(request)
return await _respond_action(
request, user, lambda: store.upgrade_mastery(user, data.key)
)
@router.post("/cosmetics/buy")
async def game_buy_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
user = require_user(request)
def reward(result):
track_action(user["uid"], "cosmetic_bought")
return await _respond_action(
request, user, lambda: store.buy_cosmetic(user, data.key), reward
)
@router.post("/cosmetics/equip")
async def game_equip_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
user = require_user(request)
return await _respond_action(
request, user, lambda: store.equip_title(user, data.key)
request, user, lambda: store.claim_quest(user, data.quest), reward
)

View File

@ -2,7 +2,6 @@
import asyncio
import logging
from datetime import datetime
from typing import Annotated, Optional
from fastapi import Depends, APIRouter, Request, WebSocket, WebSocketDisconnect
from devplacepy.models import MessageForm
@ -26,18 +25,16 @@ from devplacepy.utils import (
)
from devplacepy.seo import base_seo_context
from devplacepy.responses import respond, action_result
from devplacepy.schemas import ConversationOut, MessagesOut
from devplacepy.schemas import MessagesOut
from devplacepy.services import presence
from devplacepy.services.audit import record as audit
from devplacepy.services.correction import PENDING_SCOPE_KEY
from devplacepy.dependencies import json_or_form
from devplacepy.services.messaging import (
issue_ticket,
message_frame,
message_hub,
message_relay,
persist_message,
redeem_ticket,
)
logger = logging.getLogger(__name__)
@ -47,18 +44,6 @@ MAX_WS_ATTACHMENTS = 5
CONVERSATION_MESSAGE_LIMIT = 500
MESSAGE_GROUP_GAP_SECONDS = 300
def _grouped_with_previous(sender_uid, created_at, previous_sender_uid, previous_created_at) -> bool:
if previous_sender_uid is None or sender_uid != previous_sender_uid:
return False
try:
current_dt = datetime.fromisoformat(created_at)
previous_dt = datetime.fromisoformat(previous_created_at)
except (TypeError, ValueError):
return False
return (current_dt - previous_dt).total_seconds() <= MESSAGE_GROUP_GAP_SECONDS
def mark_conversation_read(user_uid: str, other_uid: str) -> None:
if "messages" not in db.tables:
return
@ -138,8 +123,6 @@ def get_conversation_messages(user_uid: str, other_uid: str):
result = []
msg_uids = [m["uid"] for m in msgs]
attachments_map = get_attachments_batch("message", msg_uids) if msg_uids else {}
previous_sender_uid = None
previous_created_at = None
for m in msgs:
result.append(
{
@ -148,13 +131,8 @@ def get_conversation_messages(user_uid: str, other_uid: str):
"is_mine": m["sender_uid"] == user_uid,
"time_ago": time_ago(m["created_at"]),
"attachments": attachments_map.get(m["uid"], []),
"grouped": _grouped_with_previous(
m["sender_uid"], m["created_at"], previous_sender_uid, previous_created_at
),
}
)
previous_sender_uid = m["sender_uid"]
previous_created_at = m["created_at"]
return result, other_user
@router.get("", response_class=HTMLResponse)
@ -229,19 +207,6 @@ async def search_users(request: Request, q: str = ""):
results = search_users_by_username(q, exclude_uid=user["uid"])
return JSONResponse({"results": results})
@router.get("/conversations")
async def list_conversations(request: Request):
user = require_user(request)
conversations = get_conversations(user["uid"])
payload = [ConversationOut.model_validate(c).model_dump() for c in conversations]
return JSONResponse({"conversations": payload})
@router.post("/ws-ticket")
async def create_ws_ticket(request: Request):
user = require_user(request)
token = issue_ticket(user["uid"])
return JSONResponse({"ticket": token, "expires_in": 30})
@router.post("/send")
async def send_message(request: Request, data: Annotated[MessageForm, Depends(json_or_form(MessageForm))]):
user = require_user(request)
@ -258,54 +223,37 @@ async def send_message(request: Request, data: Annotated[MessageForm, Depends(js
if message is None:
return action_result(request, "/messages")
ai_processed = await _finalize_and_broadcast(
user, message, request, client_id=data.client_id
)
frame = message_frame(
message, user.get("username", ""), data.client_id,
sender_role=user.get("role"), ai_processed=ai_processed,
)
await _finalize_and_broadcast(user, message, request)
return action_result(
request, f"/messages?with_uid={receiver_uid}", data=frame
request, f"/messages?with_uid={receiver_uid}", data={"uid": message["uid"]}
)
async def broadcast_message(
sender: dict, message: dict, client_id: Optional[str] = None,
ai_processed: bool = False,
sender: dict, message: dict, client_id: Optional[str] = None
) -> None:
frame = message_frame(
message, sender.get("username", ""), client_id,
sender_role=sender.get("role"), ai_processed=ai_processed,
)
frame = message_frame(message, sender.get("username", ""), client_id, sender_role=sender.get("role"))
message_hub.mark_delivered(message["uid"])
targets = [message["sender_uid"], message["receiver_uid"]]
await message_hub.send_to_users(targets, frame)
async def _finalize_and_broadcast(
sender: dict, message: dict, request: object, client_id: Optional[str] = None
) -> bool:
) -> None:
message_hub.mark_delivered(message["uid"])
scope = getattr(request, "scope", None)
pending = scope.get(PENDING_SCOPE_KEY) if scope is not None else None
ai_processed = bool(pending)
if pending:
await asyncio.gather(*pending, return_exceptions=True)
pending.clear()
row = get_table("messages").find_one(uid=message["uid"])
if row:
message["content"] = row["content"]
await broadcast_message(sender, message, client_id, ai_processed=ai_processed)
return ai_processed
await broadcast_message(sender, message, client_id)
def _resolve_ws_user(websocket: WebSocket):
user = _user_from_session(websocket)
if user:
return user
ticket = websocket.query_params.get("ticket", "").strip()
if ticket:
user_uid = redeem_ticket(ticket)
if user_uid:
return get_table("users").find_one(uid=user_uid)
key = websocket.headers.get("x-api-key", "").strip()
if not key:
scheme, _, credentials = websocket.headers.get("authorization", "").partition(

View File

@ -4,7 +4,6 @@ import logging
from devplacepy.database import get_correction_usage, get_modifier_usage
from devplacepy.services.manager import service_manager
from devplacepy.services.openai_gateway import quota as gateway_quota
from devplacepy.services.openai_gateway.analytics import user_spend_24h
logger = logging.getLogger(__name__)
@ -63,22 +62,4 @@ def _ai_quota(
if include_cost:
quota["spent_usd"] = round(spent, 4)
quota["limit_usd"] = round(limit, 2)
gateway_svc = service_manager.get_service("openai")
if gateway_svc is not None:
try:
owner_kind = "admin" if is_admin else "user"
cfg = gateway_svc.effective_config()
gw_limit, gw_scope, gw_rule = gateway_quota.resolve_for_owner(owner_kind, user_uid, cfg)
gw_spent = gateway_quota.spent_24h(*gw_scope)
gw_unlimited = gw_limit <= 0
quota["gateway_unlimited"] = gw_unlimited
quota["gateway_used_pct"] = (
0.0 if gw_unlimited else round(min(100.0, gw_spent / gw_limit * 100), 1)
)
if include_cost:
quota["gateway_spent_usd"] = round(gw_spent, 4)
quota["gateway_limit_usd"] = round(gw_limit, 2)
quota["gateway_pooled"] = bool(gw_rule and gw_rule.owner_id is None)
except Exception:
logger.exception("Failed to compute gateway-level AI quota for %s", user_uid)
return quota

View File

@ -99,7 +99,6 @@ from devplacepy.schemas.backups import (
BackupStoragePathOut,
)
from devplacepy.schemas.admin import (
AdminGameOut,
AdminMediaItemOut,
AdminMediaOut,
AdminNewsItemOut,

View File

@ -72,12 +72,3 @@ class AdminTrashOut(_Out):
tables: list[dict] = []
pagination: Optional[Any] = None
admin_section: Optional[str] = None
class AdminGameOut(_Out):
era_active: bool = False
era_name: str = ""
era_number: int = 0
era_started_at: str = ""
era_ends_at: str = ""
admin_section: Optional[str] = None

View File

@ -15,7 +15,6 @@ class GameCropOut(_Out):
min_level: int = 1
grow_seconds: int = 0
locked: bool = False
market_state: str = "normal"
class GamePlotOut(_Out):
@ -63,38 +62,6 @@ class GameLegacyOut(_Out):
effect: str = ""
class GameMasteryOut(_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 GameInfrastructureOut(_Out):
key: str = ""
name: str = ""
icon: str = ""
description: str = ""
cost: int = 0
min_prestige: int = 0
owned: bool = False
class GameCosmeticOut(_Out):
key: str = ""
name: str = ""
icon: str = ""
description: str = ""
cost_coins: int = 0
kind: str = ""
owned: bool = False
class GameQuestOut(_Out):
kind: str = ""
label: str = ""
@ -104,8 +71,6 @@ class GameQuestOut(_Out):
reward_xp: int = 0
claimed: bool = False
can_claim: bool = False
scope: str = "daily"
reward_stars: int = 0
class GameFarmOut(_Out):
@ -134,14 +99,6 @@ class GameFarmOut(_Out):
prestige_multiplier: float = 1.0
prestige_min_level: int = 0
prestige_available: bool = False
refactor_cost: int = 0
refactor_affordable: bool = False
refactor_carryover_pct: int = 0
refactor_carryover_preview: int = 0
grant_available: bool = False
grant_amount: int = 0
grant_reason: str = ""
treasury_balance: int = 0
streak: int = 0
daily_available: bool = False
daily_reward: int = 0
@ -150,25 +107,6 @@ class GameFarmOut(_Out):
stars: int = 0
legacy: list[GameLegacyOut] = []
steal_cooldown_seconds: int = 0
mastery_points: int = 0
mastery_points_earned_total: int = 0
mastery: list[GameMasteryOut] = []
infrastructure: list[GameInfrastructureOut] = []
defense_level: int = 0
defense_tier_name: str = ""
defense_upkeep_daily: int = 0
defense_next_cost: int = 0
cosmetics: list[GameCosmeticOut] = []
active_title: str = ""
underdog_boost_seconds_remaining: int = 0
mastery_analytics_unlocked: bool = False
lifetime_coins_earned: int = 0
lifetime_harvests: int = 0
harvests_week: int = 0
era_active: bool = False
era_name: str = ""
era_coins: int = 0
era_harvests: int = 0
class GameStateOut(_Out):
@ -192,9 +130,6 @@ class GameLeaderboardEntryOut(_Out):
total_harvests: int = 0
prestige: int = 0
score: int = 0
raid_avg: float = 0.0
time_to_kernel_seconds: int = 0
title: str = ""
class GameLeaderboardOut(_Out):

View File

@ -70,7 +70,6 @@ class MessageItemOut(_Out):
is_mine: bool = False
time_ago: Optional[str] = None
attachments: list[AttachmentOut] = []
grouped: bool = False
class NotificationItemOut(_Out):

View File

@ -50,14 +50,6 @@ Five content/behaviour mechanics keep the fleet from reading as machine-generate
- **Usernames read like nerd handles, not real names.** Bots do NOT sign up with faker person names; they pick devRant/Hacker-News-style handles. Two layers, LLM-first with an offline fallback: (1) `llm.generate_handle_candidates(persona)` asks the model for ~8 distinct handles grounded in the persona and its `SEARCH_TERMS` interests (tech nouns, leetspeak, adjective+noun, creatures, short word+number), each run through `handles.sanitize_handle`; the pool is cached on `BotState.handle_candidates` and consumed by `_next_handle()`. (2) `handles.make_handle(interests)` is the pure, offline algorithmic generator (curated word banks + probabilistic leetspeak + number/separator/casing decoration, persona-seeded from `SEARCH_TERMS`), used as the fallback whenever the LLM pool is empty or a signup collides. Both layers emit only `[A-Za-z0-9_-]`, 3 to 20 chars (the old `first.last` styles silently failed signup validation, which rejects dots). On a third signup retry a random number is appended to bust collisions. Word banks and tuning constants live in `services/bot/handles.py`; never reintroduce faker person-name handles.
- **Bots engage each other, and threads deepen.** `ArticleRegistry` allows up to `max_per_article` holders per article (admin `bot_max_per_article`, default 2), each with a **distinct category** (stored as `holders: [{bot, category, time}]`; old `{bot, time}` rows are normalized on load), so two bots can post different angles on the same trending news - which gives them each other's posts to discuss. A holder ages out after `ttl_days` (admin `bot_article_ttl_days`, default 7). `_engage_community()` (called once per normal/deep session after notifications) goes to `/feed?tab=recent|trending`, ranks non-own posts with a preference for `known_users` authors, opens one, comments, and replies into the comment thread. The on-post reply-to-comment probability is also raised so multi-bot threads actually form. `reserve(title, owner, category)` is idempotent per owner and enforces the same distinct-angle / max-holders rules as `reserve_unused`.
## Profile disclosure signal (soft bot marker)
Every bot's profile must carry a vague, never-literal hint that the account is synthetic, anchored on `config.HOME_URL` (`https://devplace.net`). The deterministic marker is the host `devplace.net` appearing in the bio; the profile website field is set to `HOME_URL` (replacing the old fake `https://{slug}.dev`). Three layers, all funneled through the single `_update_profile` choke point (so the procedural path and the AI-decision `update_profile` action both comply):
- **Generation:** `llm.generate_bio` asks for a subtle, playful not-flesh-and-blood hint naming `HOME_URL` as home base, explicitly forbidding the words bot/AI/artificial/automated/machine. `generate_profile_fields` returns `HOME_URL` as the website.
- **Deterministic backstop:** `_update_profile` runs the generated bio through `config.ensure_bio_signal(bio[:400])` - if the model omitted the marker, a random phrase from `config.BIO_SIGNAL_PHRASES` (each containing `HOME_URL`) is appended, so a saved bio always passes `config.bio_has_signal`.
- **Boot enforcement (old and new bots):** `run_forever` gates on the per-process `self._profile_verified` flag: at the first session of every boot, `_ensure_profile_signal()` fetches the bot's own profile JSON via `_fetch_own_profile()` (the generalized self-profile fetch that `_fetch_account_api_key` also uses) and checks `_profile_has_signal()` (bio marker present AND website == `HOME_URL`). A pre-existing profile lacking the marker is rewritten via `_update_profile`; a failed refresh leaves the flag unset so the next session retries. Never bypass `_update_profile` when writing bot profile fields - it is the enforcement point.
## AI-driven decisions and identity cards (`bot_ai_decisions`)
Opt-in mechanic (off by default; full design and cost model in `aibots.md`). When `bot_ai_decisions` is on, a bot replaces the procedural action cascade with one LLM decision call per page, driven by a unique AI-generated identity. **Do not regress the grounding and the kill-switch fallback.**

View File

@ -87,28 +87,25 @@ class BotAuthMixin:
self._log(f"Signup failed, retrying as {self.state.username}")
return False
async def _fetch_own_profile(self) -> dict:
async def _fetch_account_api_key(self) -> str:
if not self.state.username:
return {}
return ""
try:
data = await self.b.page.evaluate(
key = await self.b.page.evaluate(
"""async (username) => {
const resp = await fetch(`/profile/${username}`, {
headers: {Accept: 'application/json'},
});
if (!resp.ok) return null;
return await resp.json();
if (!resp.ok) return '';
const data = await resp.json();
return data.api_key || '';
}""",
self.state.username,
)
except Exception as e:
logger.debug("fetch own profile failed: %s", e)
return {}
return data if isinstance(data, dict) else {}
async def _fetch_account_api_key(self) -> str:
profile = await self._fetch_own_profile()
return (profile.get("api_key") or "").strip()
logger.debug("fetch account api key failed: %s", e)
return ""
return (key or "").strip()
async def _adopt_account_api_key(self) -> bool:
for attempt in range(5):

View File

@ -110,7 +110,6 @@ class DevPlaceBot(
self._session_comments = 0
self._session_comment_cap = 0
self._session_mention_replies = 0
self._profile_verified = False
self._post_cache: list[tuple[str, str, str, str, str]] = []
self._project_cache: list[tuple[str, str]] = []
self._issue_cache: list[tuple[str, str]] = []

View File

@ -7,16 +7,6 @@ from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL, BOT_DIR
BASE_URL_DEFAULT = "https://pravda.education"
API_URL_DEFAULT = INTERNAL_GATEWAY_URL
NEWS_API_DEFAULT = "https://news.app.molodetz.nl/api"
HOME_URL = "https://devplace.net"
HOME_HOST = "devplace.net"
BIO_SIGNAL_PHRASES = [
f"Compiled with care at {HOME_URL}.",
f"Homegrown at {HOME_URL}.",
f"Assembled, not raised, at {HOME_URL}.",
f"Running my daily routines from {HOME_URL}.",
f"Tirelessly online, courtesy of {HOME_URL}.",
]
MODEL_DEFAULT = INTERNAL_MODEL
INPUT_COST_PER_1M_DEFAULT = 0.14
OUTPUT_COST_PER_1M_DEFAULT = 0.28
@ -322,15 +312,3 @@ def persona_article_score(article: dict, persona: str) -> float:
return 0.0
haystack = f"{article.get('title', '')} {article.get('description', '')}".lower()
return float(sum(1 for term in terms if term.lower() in haystack))
def bio_has_signal(bio: str) -> bool:
return HOME_HOST in (bio or "").lower()
def ensure_bio_signal(bio: str) -> str:
text = (bio or "").strip()
if bio_has_signal(text):
return text
phrase = random.choice(BIO_SIGNAL_PHRASES)
return f"{text} {phrase}".strip()

View File

@ -13,7 +13,6 @@ from devplacepy import stealth
from devplacepy.services.bot.config import (
GIST_LANGUAGES,
GIST_MIN_LINES,
HOME_URL,
PERSONA_GIST_FLAVOR,
PERSONA_LANGUAGES,
SEARCH_TERMS,
@ -672,9 +671,7 @@ class LLMClient:
def generate_bio(self) -> str:
return self._call(
"Write a short bio for a developer profile. 1-2 sentences. What they work on, what they like. Sound human. "
f"End with one subtle, playful hint that the account is not exactly flesh and blood, naming {HOME_URL} as home base. "
"Never use the words bot, AI, artificial, automated, or machine. No em dashes.",
"Write a short bio for a developer profile. 1-2 sentences. What they work on, what they like. Sound human. No em dashes.",
"Bio:",
)
@ -688,7 +685,8 @@ class LLMClient:
)[:80]
slug = re.sub(r"[^a-z0-9_-]", "", handle.lower()) or "dev"
git_link = f"https://github.com/{slug}"
return location, git_link, HOME_URL
website = f"https://{slug}.dev"
return location, git_link, website
def generate_dm(self, persona: str = "", context: str = "") -> str:
extra = {

View File

@ -369,8 +369,10 @@ class BotLoopMixin:
await self._warm_cache()
self._log(f"Session active ({mood}) as {self._identity()}")
if not self._profile_verified:
self._profile_verified = await self._ensure_profile_signal()
if not self.state.profile_filled:
await self._update_profile()
await b.goto(f"{self.base_url}/feed")
await b._idle(0.8, 2.0)
unread = await self._unread_notification_count()
if unread:

View File

@ -6,12 +6,9 @@ import logging
import random
from devplacepy.services.bot.config import (
HOME_URL,
MAX_THREAD_REPLIES,
MENTION_REPLIES_PER_SESSION,
PROJECT_STATUSES,
bio_has_signal,
ensure_bio_signal,
)
logger = logging.getLogger(__name__)
@ -145,21 +142,6 @@ class BotSocialMixin:
return True
return False
async def _profile_has_signal(self) -> bool:
profile = await self._fetch_own_profile()
user = profile.get("profile_user") or {}
website = (user.get("website") or "").strip().rstrip("/")
return bio_has_signal(user.get("bio") or "") and website == HOME_URL
async def _ensure_profile_signal(self) -> bool:
if self.state.profile_filled and await self._profile_has_signal():
return True
self._log("Profile signal missing, refreshing profile")
ok = await self._update_profile()
await self.b.goto(f"{self.base_url}/feed")
await self.b._idle(0.8, 2.0)
return ok
async def _update_profile(self) -> bool:
b = self.b
await b.goto(f"{self.base_url}/profile/{self.state.username}")
@ -173,7 +155,7 @@ class BotSocialMixin:
if not bio:
self._log("Update profile: bio generation failed")
return False
bio = ensure_bio_signal(bio[:400])[:500]
bio = bio[:500]
await b.fill("textarea[name='bio']", bio)
await b._idle(0.3, 0.8)

View File

@ -79,10 +79,6 @@ Beyond the avatar, Devii has a `client`/browser channel using the same request/r
**Target selection (visibility-aware).** Unlike replies/traces, which `_emit` *broadcasts* to every tab, a browser request is sent to one **target** chosen by `_pick_target()`. The terminal reports each connection's `document` visibility and focus via `{"type":"visibility"}` (on connect, `focus`, `blur`, and `visibilitychange`); the session stores it in `_conn_meta` and ranks connections `(focused, visible, attach_seq)`, so the command runs on the tab the user is actually looking at, falling back to the most recently attached when none reports focus. **Regression to avoid: do NOT route to a single "last-attached primary" socket** - with the `/docs` auto-open tab or any second tab, `reload_page`/`navigate_to` ran on the wrong (hidden) tab while still reporting success, so "the reload did not happen" from the user's view. `run_js` is gated by the `devii_allow_eval` config field (default on), checked in `ClientController` before any round-trip. Overlays (`.devii-hl-box`, `.devii-hl-callout`, `.devii-toast`) attach to `document.body`, not the terminal.
## Stale API key auto-recovery (401 self-healing)
A user session captures the owner's platform `api_key` into its frozen `Settings` at build time (both `Settings.ai_key` for the gateway and `Settings.platform_api_key` for REST). Regenerating that key (profile regenerate, Devii tool, `devplace apikey reset` - possibly from another worker or process) would strand every live session with a permanent `401 Unauthorized` on all LLM and platform calls. Recovery is **reactive at the two HTTP chokepoints**, never proactive session invalidation (which is per-process and cannot reach the hub on the lock-owner worker): `hub.get_or_create` builds a `_user_key_resolver(owner_id)` (a fresh `users.api_key` DB read, user owners only - guests get `None` and keep the internal gateway key) and passes it to `LLMClient(settings, key_resolver=)` and through `DeviiSession(key_resolver=)` into `PlatformClient`. On a 401 (`LLMClient._post`) or a 401/redirect-to-login (`PlatformClient.call` via `_auth_failed`), the client calls `_refresh_key()`: resolve the current key, and only when it is non-empty AND differs from the header already sent, rewrite the auth headers and retry the request **once**. An unchanged or unresolvable key skips the retry so a genuinely invalid credential still fails closed with the original error. The resolver read is cross-worker correct because it goes straight to SQLite, not any per-process cache.
## Shared browser session (auth adoption)
The terminal and the browser are one session. `/devii/ws` resolves its owner from the browser's `session` cookie (`_resolve_ws_owner`), so the terminal is whoever the browser is logged in as. Agent-initiated auth propagates: the session watches its own trace stream (`_trace`) and, on a successful `login`/`signup`, captures the real session token the `PlatformClient` minted against this instance (`session_cookie()`) and broadcasts `{"type":"auth","action":"adopt"}`; the browser navigates to single-use `GET /devii/adopt` which sets the httpOnly `session` cookie and redirects (reload). On `logout` it broadcasts `{"type":"auth","action":"logout"}` and the browser goes to `/auth/logout`. Browser->terminal: login/logout are navigations that reconnect the WS; a cross-tab change is caught by a focus check against `/devii/session` that reloads. The httpOnly cookie is only ever set/cleared by HTTP endpoints, never JS.

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from ..spec import Action, Param
from ._shared import body, confirm, path, query
from ._shared import body, path
GAME_ACTIONS: tuple[Action, ...] = (
@ -24,18 +24,10 @@ GAME_ACTIONS: tuple[Action, ...] = (
name="game_leaderboard",
method="GET",
path="/game/leaderboard",
summary="List the top Code Farm players on a given board",
description=(
"Boards: score (default, overall composite score), prestige, harvests "
"(this week), raids (avg coins per successful raid, min 3 raids), "
"time_to_kernel (fastest since last refactor), fair_play (rewards recent "
"activity over hoarding), era (current Era-only leaderboard, empty when no "
"Era is running)."
),
summary="List the top Code Farm players",
handler="http",
requires_auth=False,
read_only=True,
params=(query("board", "Leaderboard board key, defaults to score."),),
),
Action(
name="game_view_farm",
@ -144,41 +136,18 @@ GAME_ACTIONS: tuple[Action, ...] = (
name="game_claim_quest",
method="POST",
path="/game/quests/claim",
summary="Claim a completed daily or weekly Code Farm quest/contract reward",
summary="Claim a completed daily Code Farm quest reward",
handler="http",
requires_auth=True,
params=(
body("quest", "Quest kind: plant, harvest, water, or earn.", required=True),
body(
"scope",
"daily (default) or weekly (requires the Legacy Contracts Mastery upgrade).",
required=False,
),
),
),
Action(
name="game_prestige",
method="POST",
path="/game/prestige",
summary=(
"Refactor (prestige) your Code Farm for a permanent coin bonus and Stars. "
"Requires level 10 AND a coin fee that scales with prestige and wealth "
"(shown as refactor_cost in game_state); the fee funds the community "
"treasury and a fraction of the remaining coins carries over. "
"Irreversible, confirmation required"
),
handler="http",
requires_auth=True,
params=(confirm(),),
),
Action(
name="game_claim_grant",
method="POST",
path="/game/grant",
summary=(
"Claim the weekly Code Farm community grant, paid from the treasury of "
"refactor fees (active low-balance, low-prestige farms only)"
),
summary="Refactor (prestige) your Code Farm for a permanent coin bonus and Stars (requires level 10)",
handler="http",
requires_auth=True,
),
@ -186,63 +155,15 @@ GAME_ACTIONS: tuple[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, carryover)",
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, defense, or carryover.",
"Legacy key: autoharvest, multiplier, speed, plots, or defense.",
required=True,
),
),
),
Action(
name="game_upgrade_mastery",
method="POST",
path="/game/mastery",
summary="Spend Mastery points on a permanent Mastery upgrade (unlocked at prestige 50+): autoreplant, analytics, or contracts",
handler="http",
requires_auth=True,
params=(
body("key", "Mastery key: autoreplant, analytics, or contracts.", required=True),
),
),
Action(
name="game_buy_infrastructure",
method="POST",
path="/game/infrastructure/buy",
summary="Buy a permanent Infrastructure building (registry, canary, observability); expensive, prestige-gated, big coin sinks",
handler="http",
requires_auth=True,
params=(
body("key", "Infrastructure key: registry, canary, or observability.", required=True),
),
),
Action(
name="game_upgrade_defense",
method="POST",
path="/game/defense/upgrade",
summary="Upgrade your farm's Defense tier: reduces raid losses and adds steal grace, but costs an ongoing daily coin upkeep proportional to your wealth",
handler="http",
requires_auth=True,
),
Action(
name="game_buy_cosmetic",
method="POST",
path="/game/cosmetics/buy",
summary="Buy a purely cosmetic title or plot skin with coins (no gameplay effect)",
handler="http",
requires_auth=True,
params=(body("key", "Cosmetic key from the farm's cosmetics list.", required=True),),
),
Action(
name="game_equip_cosmetic",
method="POST",
path="/game/cosmetics/equip",
summary="Equip an owned cosmetic title so it shows on the leaderboard",
handler="http",
requires_auth=True,
params=(body("key", "An owned title cosmetic key.", required=True),),
),
)

View File

@ -117,57 +117,4 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
requires_admin=True,
params=(path("source_model", "Source model name."), confirm()),
),
Action(
name="gateway_quota_rules",
method="GET",
path="/admin/gateway/quota-rules",
summary="List AI gateway quota rules and the current global defaults (admin only)",
description=(
"Returns JSON: every quota rule (each scoped by any combination of role/owner_kind, "
"a specific user uid, and an app_reference label) with its 24h limit, current 24h spend "
"against that exact scope, active flag, and label, plus the global per-role default caps "
"used when no rule matches a request."
),
handler="http",
requires_admin=True,
read_only=True,
),
Action(
name="gateway_quota_rule_set",
method="POST",
path="/admin/gateway/quota-rules",
summary="Create or update an AI gateway quota rule (admin only)",
description=(
"Caps rolling-24h USD spend on /openai/v1/*. Scope by any combination of owner_kind "
"(internal/key/user/admin/anonymous), a specific owner_id (user uid), and app_reference "
"(the X-App-Reference header apps send). At least one of the three must be set - an "
"unscoped cap belongs in the global default fields on the gateway service config instead. "
"Leaving a dimension blank makes it a wildcard: an app_reference-only rule pools spend "
"across every caller using that app; an owner_kind-only rule pools spend across every "
"caller of that role. Setting owner_id pins the rule to one specific caller. When several "
"rules match one request, the MOST SPECIFIC one wins (most non-blank dimensions); ties "
"break toward the smaller limit. limit_usd of 0 means unlimited for that rule. Pass uid "
"to update an existing rule instead of creating a new one."
),
handler="http",
requires_admin=True,
params=(
body("uid", "Existing rule uid to update; omit to create a new rule."),
body("owner_kind", "Role to scope by: internal, key, user, admin, or anonymous. Blank = any role."),
body("owner_id", "Specific user uid to scope by. Blank = any caller of the matched role."),
body("app_reference", "App label to scope by (the X-App-Reference header). Blank = any app."),
Param(name="limit_usd", location="body", description="Rolling 24h USD cap for this rule. 0 = unlimited.", required=True, type="number"),
Param(name="is_active", location="body", description="Whether the rule is enforced ('1' or '0'). Defaults to active.", required=False, type="boolean"),
body("label", "Optional admin-facing note describing what this rule is for."),
),
),
Action(
name="gateway_quota_rule_delete",
method="DELETE",
path="/admin/gateway/quota-rules/{uid}",
summary="Delete an AI gateway quota rule (admin only, confirmation required)",
handler="http",
requires_admin=True,
params=(path("uid", "Quota rule uid."), confirm()),
),
)

View File

@ -58,10 +58,8 @@ CONFIRM_REQUIRED = {
"notification_reset",
"gateway_provider_delete",
"gateway_model_delete",
"gateway_quota_rule_delete",
"email_account_delete",
"email_delete_message",
"game_prestige",
}
CONDITIONAL_CONFIRM = {

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Callable
from typing import Any
import httpx
@ -19,11 +19,7 @@ LOGIN_PATH = "/auth/login"
class PlatformClient:
def __init__(
self,
base_url: str,
timeout_seconds: float,
api_key: str = "",
key_resolver: Callable[[], str] | None = None,
self, base_url: str, timeout_seconds: float, api_key: str = ""
) -> None:
headers = {
"User-Agent": "devii/0.1",
@ -33,7 +29,6 @@ class PlatformClient:
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers["X-API-Key"] = api_key
self._key_resolver = key_resolver
self._client = stealth.stealth_async_client(
base_url=base_url,
timeout=timeout_seconds,
@ -111,10 +106,6 @@ class PlatformClient:
response = await self._send(
method, path, params=params, data=data, files=files, headers=headers
)
if self._auth_failed(path, response) and self._refresh_key():
response = await self._send(
method, path, params=params, data=data, files=files, headers=headers
)
if path != LOGIN_PATH and LOGIN_PATH in str(response.url):
self.authenticated = False
@ -141,29 +132,6 @@ class PlatformClient:
)
return response
@staticmethod
def _auth_failed(path: str, response: httpx.Response) -> bool:
if response.status_code == 401:
return True
return path != LOGIN_PATH and LOGIN_PATH in str(response.url)
def _refresh_key(self) -> bool:
if self._key_resolver is None:
return False
try:
key = self._key_resolver()
except Exception: # noqa: BLE001 - refresh is best-effort; the original failure still surfaces
logger.exception("Credential refresh failed after auth failure")
return False
if not key or key == self._client.headers.get("X-API-Key"):
return False
self._client.headers["Authorization"] = f"Bearer {key}"
self._client.headers["X-API-Key"] = key
self.authenticated = True
self.username = self.username or "api-key"
logger.info("Refreshed platform credentials after auth failure")
return True
def _open_upload(self, raw_path: str) -> tuple[str, bytes]:
candidate = Path(raw_path).expanduser()
if not candidate.is_file():

View File

@ -22,16 +22,6 @@ logger = logging.getLogger("devii.hub")
SettingsBuilder = Callable[[str, str, str, bool], "tuple[Settings, Pricing]"]
def _user_key_resolver(owner_id: str) -> Callable[[], str]:
def resolve() -> str:
if "users" not in db.tables:
return ""
row = db["users"].find_one(uid=owner_id, deleted_at=None)
return (row or {}).get("api_key") or ""
return resolve
class DeviiHub:
def __init__(self, settings_builder: SettingsBuilder) -> None:
self._build = settings_builder
@ -62,10 +52,7 @@ class DeviiHub:
if session is not None:
return session
settings, pricing = self._build(api_key, base_url, owner_kind, is_admin)
key_resolver = (
_user_key_resolver(owner_id) if owner_kind == "user" else None
)
llm = LLMClient(settings, key_resolver=key_resolver)
llm = LLMClient(settings)
# Persistent, owner-isolated stores for signed-in users; ephemeral in-memory for guests.
# The `docs` channel (Docii) is a self-contained documentation assistant: it gets its own
# ephemeral stores so Devii's tasks, lessons, behavior and virtual tools never leak into it
@ -95,7 +82,6 @@ class DeviiHub:
is_admin=is_admin,
is_primary_admin=is_primary_admin,
channel=channel,
key_resolver=key_resolver,
)
if owner_kind == "user":
saved = self._stores["conversations"].load(owner_kind, owner_id, channel)

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from typing import Any, Callable
from typing import Any
import httpx
@ -16,13 +16,8 @@ logger = logging.getLogger("devii.llm")
class LLMClient:
def __init__(
self,
settings: Settings,
key_resolver: Callable[[], str] | None = None,
) -> None:
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._key_resolver = key_resolver
self._client = stealth.stealth_async_client(
timeout=settings.timeout_seconds,
headers={
@ -34,26 +29,6 @@ class LLMClient:
async def aclose(self) -> None:
await self._client.aclose()
async def _post(self, payload: dict[str, Any]) -> httpx.Response:
response = await self._client.post(self._settings.ai_url, json=payload)
if response.status_code == 401 and self._refresh_key():
response = await self._client.post(self._settings.ai_url, json=payload)
return response
def _refresh_key(self) -> bool:
if self._key_resolver is None:
return False
try:
key = self._key_resolver()
except Exception: # noqa: BLE001 - refresh is best-effort; the original 401 still surfaces
logger.exception("Credential refresh failed after 401")
return False
if not key or self._client.headers.get("Authorization") == f"Bearer {key}":
return False
self._client.headers["Authorization"] = f"Bearer {key}"
logger.info("Refreshed model endpoint credentials after 401")
return True
async def complete(
self,
messages: list[dict[str, Any]],
@ -67,7 +42,7 @@ class LLMClient:
}
try:
logger.debug("LLM request with %d messages", len(messages))
response = await self._post(payload)
response = await self._client.post(self._settings.ai_url, json=payload)
except httpx.HTTPError as exc:
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
@ -107,7 +82,7 @@ class LLMClient:
"temperature": temperature,
}
try:
response = await self._post(payload)
response = await self._client.post(self._settings.ai_url, json=payload)
except httpx.HTTPError as exc:
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
if response.status_code >= 400:
@ -136,7 +111,7 @@ class LLMClient:
"temperature": 0.0,
}
try:
response = await self._post(payload)
response = await self._client.post(self._settings.ai_url, json=payload)
except httpx.HTTPError as exc:
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
if response.status_code >= 400:

View File

@ -75,7 +75,6 @@ class DeviiSession:
is_admin: bool = False,
is_primary_admin: bool = False,
channel: str = "main",
key_resolver: Any = None,
) -> None:
self.owner_kind = owner_kind
self.owner_id = owner_id
@ -90,10 +89,7 @@ class DeviiSession:
self._llm = llm
self._lessons = lessons
self.client = PlatformClient(
settings.base_url,
settings.timeout_seconds,
settings.platform_api_key,
key_resolver=key_resolver,
settings.base_url, settings.timeout_seconds, settings.platform_api_key
)
self.avatar = AvatarController()
self.browser = ClientController(
@ -524,8 +520,8 @@ class DeviiSession:
f"{self._system_prompt}\n\n"
f"{CA_IWP_SYSTEM_FRAGMENT}\n\n"
f"{channel_block}\n\n"
f"{section}\n\n"
f"{self._clock_line()}"
f"{self._clock_line()}\n\n"
f"{section}"
)
def _clock_line(self) -> str:
@ -542,7 +538,7 @@ class DeviiSession:
offset = local_now.utcoffset()
offset_text = _format_offset(offset)
local = (
f" The user's local time is {local_now.strftime('%Y-%m-%d %Hh')} "
f" The user's local time is {local_now.strftime('%Y-%m-%d %H:%M:%S')} "
f"({tz_name}, UTC{offset_text})."
)
except Exception: # noqa: BLE001 - unknown tz name: fall back to UTC only
@ -551,21 +547,16 @@ class DeviiSession:
offset = timedelta(minutes=self._tz_offset_minutes)
local = (
f" The user's local time is "
f"{(now + offset).strftime('%Y-%m-%d %Hh')} "
f"{(now + offset).strftime('%Y-%m-%d %H:%M:%S')} "
f"(UTC{_format_offset(offset)})."
)
return (
f"# CURRENT TIME\n"
f"The current UTC time is approximately {now.strftime('%Y-%m-%d %Hh')} UTC "
f"(rounded to the hour for situational awareness only).{local} "
f"The current UTC time is {now.strftime('%Y-%m-%dT%H:%M:%S')}Z.{local} "
"When the user gives a wall-clock time (for example '3pm' or 'tomorrow at 09:00'), "
"interpret it in the user's local timezone and convert it to UTC for the run_at field. "
"For a relative request (for example 'in 40 seconds' or 'in 2 hours'), use delay_seconds "
"instead and do not compute an absolute time - it is applied against the exact time on "
"the server when the task is created, regardless of the rounding above. If you ever need "
"the exact current time to the second - for example to compute an absolute run_at from a "
"phrase you cannot express as delay_seconds - call the current_time tool first; never "
"derive an absolute run_at from the rounded line above."
"instead and do not compute an absolute time."
)
def _stored_timezone(self) -> str:

View File

@ -56,23 +56,6 @@ SCHEDULE_FIELDS: tuple[Param, ...] = (
)
TASK_ACTIONS: tuple[Action, ...] = (
Action(
name="current_time",
method="LOCAL",
path="",
summary="Get the exact current UTC time, to the second",
description=(
"The CURRENT TIME line in the system prompt is rounded to the hour to stay "
"cache-friendly. Call this tool first whenever you need the precise current time - "
"in particular before computing any absolute run_at for create_task/update_task from "
"a relative phrase you cannot express via delay_seconds. For an ordinary relative "
"delay ('in 40 seconds', 'in 2 hours'), you do not need this: delay_seconds is applied "
"against the server's own clock at creation time and is always exact regardless."
),
handler="task",
requires_auth=False,
params=(),
),
Action(
name="create_task",
method="LOCAL",

View File

@ -69,7 +69,6 @@ class TaskController:
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
handlers = {
"current_time": self.current_time,
"create_task": self.create_task,
"list_tasks": self.list_tasks,
"get_task": self.get_task,
@ -82,9 +81,6 @@ class TaskController:
raise ToolInputError(f"Unknown task tool: {name}")
return handler(arguments)
def current_time(self, arguments: dict[str, Any]) -> str:
return json.dumps({"utc": to_iso(now_utc())}, ensure_ascii=False)
def create_task(self, arguments: dict[str, Any]) -> str:
prompt = str(arguments.get("prompt", "")).strip()
if not prompt:

View File

@ -4,7 +4,7 @@ This file documents the Code Farm idle game. Claude Code auto-loads it when a fi
## Overview
`game/` package (routers, mounted at `/game`) is the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,quests/claim}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`.
`game/` package (routers, mounted at `/game`) is the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,quests/claim}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`.
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.
@ -14,7 +14,7 @@ A cooperative-and-competitive idle game (Farmville-style) mounted at `/game`, me
## Tables
`game_farms` (one per user, `coins`/`xp`/`level`/`ci_tier`/`plot_count`/`total_harvests`, plus `prestige`/`streak`/`last_daily_at`/`last_grant_week`, the four `perk_*` columns, and the endgame `stars` + six `legacy_*` columns including `legacy_carryover`), `game_plots` (`farm_uid`/`slot_index`/`crop_key`/`planted_at`/`ready_at`/`watered_by`), `game_steals` (`thief_uid`/`owner_uid`/`slot_index`/`crop_key`/`coins`/`stolen_at`, the per-pair steal-cooldown ledger), and `game_treasury` (a single row `uid="treasury-main"` holding `balance`/`collected_total`/`granted_total`, the refactor-fee redistribution 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.
`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/`)
@ -42,7 +42,7 @@ Five further systems layer onto the base loop, every one defaulting gracefully f
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). **Refactoring costs a dynamic coin fee** - see "Refactor fee, carry-over, treasury, and community grant" below.
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.
@ -55,63 +55,3 @@ The infinite progression for maxed farms. Six new default-0 `game_farms` columns
**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.
## The economy rebalance (Market Saturation, Infrastructure, Defense, Cosmetics, Mastery, secondary leaderboards, Eras, Underdog, weekly Contracts - all backwards compatible)
A second large layer added on top of everything above, aimed at flattening runaway whale inequality (unbounded multiplicative prestige against finite content) and giving the game long-term excitement beyond "hit the Kernel + high-prestige wall." Every mechanic below defaults to a no-op for a farm that has never touched it - new `game_farms` columns are `0`/`""`, new tables start empty, and the Era system in particular is fully dormant (byte-identical to pre-rebalance behavior) until an admin starts the first Era.
**One shared choke point underlies all the new counters.** `store/common.py::credit_farm(farm, *, coins=0, xp=0, harvests=0, era_active=False, extra=None)` is the single place that updates `coins`/`xp`/`level`/`total_harvests` **and** the new shadow counters (`lifetime_coins_earned`, `lifetime_harvests`, and - only when `era_active` - `era_coins`/`era_harvests`) together, so no future coin-crediting feature has to remember to touch five counters at five call sites. `harvest`, `_auto_harvest`, `steal` (thief side), `claim_daily`, and `claim_quest` all route through it instead of their old inline `_update_farm` math. `credit_farm` floors both `coins` and `lifetime_coins_earned` at zero (`max(0, ...)`) as a defense-in-depth guard - no current caller passes a negative `coins` delta, but this is the shared choke every future coin-crediting feature is meant to route through, so the floor costs nothing and forecloses a whole class of future bug.
### Every purchase/upgrade is atomic against concurrent requests (`store/common.py::conditional_update_farm`)
`uvicorn --workers N` means two nearly-simultaneous requests for the same user (a double-click, or two requests hashed to different workers) can run truly in parallel across separate processes - a plain "read the farm, check a precondition in Python, then write" is a classic TOCTOU race under that model. Every mutation that spends a resource against a precondition - `buy_plot`, `upgrade_ci` (`store/farm.py`), `upgrade_perk`, `upgrade_legacy`, `prestige` (`store/actions.py`), `buy_infrastructure`, `upgrade_defense`, `charge_upkeep` (lazy, runs on every `serialize_farm`), `buy_cosmetic`, and `upgrade_mastery` - goes through `conditional_update_farm(farm_uid, set_clause, where_clause, params)`: one raw SQL `UPDATE game_farms SET ... WHERE uid = :farm_uid AND (<precondition>)`, returning the affected row count via `db.executable.execute(...).rowcount` (not `dataset`'s wrapped `db.query()`, which does not expose `rowcount`). The precondition and the write happen as a single atomic statement, so two concurrent attempts against the same stale precondition can never both succeed - the loser's `rowcount` is `0`, and the function re-reads the farm to produce the correct, specific `GameError` (already owned / maxed out / insufficient funds / state changed - refresh and retry).
**Load-bearing gotcha: every precondition column must be `COALESCE`d.** None of `prestige`, `perk_*`, `legacy_*`, `stars`, `mastery_*`, `infra_*`, or `defense_level` are set in `ensure_farm`'s insert - a real, never-yet-touched farm has them as SQL `NULL`, not `0`, and `NULL = 0` evaluates to `NULL` (false) in a `WHERE` clause. A first version of this fix compared bare `column = :current_level` and was verified "safe" only because the verification script had incorrectly pre-seeded those columns to `0` - against a genuinely fresh farm it silently rejected every legitimate first purchase. Every precondition and every arithmetic `SET` on a column that is not set at farm creation must read `COALESCE(column, 0)`; `coins`, `plot_count`, and `ci_tier` are the only farm columns set in `ensure_farm` and are the only ones safe to compare bare.
`buy_plot` additionally reserves the plot slot atomically (`WHERE plot_count = :current_count AND coins >= :cost`) **before** inserting the `game_plots` row - `idx_game_plots_farm (farm_uid, slot_index)` is not a unique index, so without the reservation two concurrent buys could insert two rows at the same `slot_index` (silent data corruption, not just a rejected purchase). `prestige` reserves the whole refactor transition atomically first (`WHERE COALESCE(prestige, 0) = :old_prestige`, in the same statement as every other reset field) and only mutates `game_plots` after that reservation wins, so a losing concurrent `prestige` call touches no plot data at all. Verified by forcing real concurrent OS processes (not threads - `dataset` gives each thread its own pooled connection, and enough of them exhausts the pool and produces `database is locked` noise that has nothing to do with the game logic) against genuinely fresh, un-seeded farm state; every purchase's final coins/stars/level exactly matches hand-computed expected totals under contention, never a partial or double charge.
### 1. Market Saturation (`economy.py` + `store/market.py`)
A new, genuinely new table `game_market_ticks` (`crop_key`, `hour_bucket` = UTC `"YYYY-MM-DDTHH"`, `harvests` count, unique on `(crop_key, hour_bucket)`) tracks the last `MARKET_WINDOW_HOURS` (48h) of production **per crop, globally** - recorded only for owner harvests/auto-harvests (`store/actions.py` calls `market.record_harvest_tick` after a successful clear), deliberately **not** for steals (a raid moves already-produced value, it does not print new supply, so counting it would double-count). `market.recent_harvests(crop_key, window_hours)` sums the trailing buckets via a raw `SELECT SUM` (cached 30s in a plain `TTLCache`, cosmetic/economic staleness only, no `cache_state` wiring needed). `economy.market_saturation_factor(recent_harvests)` steps the payout down through `MARKET_SATURATION_TIERS` (100% -> 40% past 600 recent harvests); `economy.market_buff_factor(crop_key, saturation_factor)` gives the four starter crops (`MARKET_BUFFED_CROPS`) up to `MARKET_BUFF_CAP` (+15%) relief while the market is saturated. `market.market_factor_for(crop_key)` composes both into one multiplier, threaded as the new `market_factor` parameter on `economy.effective_reward_coins`/`steal_reward_coins`/`crop_payload` (default `1.0`, so any caller that omits it is unaffected). `GameCropOut.market_state` (`"normal"`/`"saturated"`/`"boosted"`) surfaces the live signal in the shop list before planting - both the server-rendered `_game_grid.html` plant `<option>` and its `GameFarm.js` JS mirror append a `" (saturated)"`/`" (boosted)"` text suffix to the crop label (an `<option>` cannot hold markup, so this is plain text, not a styled badge). `market.prune_ticks(older_than_hours=96)` keeps the table small; CLI `devplace game market prune`.
### 2. Coin sinks: Infrastructure, Defense, Cosmetics (`economy.py` + `store/infrastructure.py`, `store/defense.py`, `store/cosmetics.py`)
- **Infrastructure** (`POST /game/infrastructure/buy`, `store.buy_infrastructure`): three one-time, boolean-owned, prestige-gated buildings (`economy.INFRASTRUCTURE`, new `game_farms` columns `infra_registry`/`infra_canary`/`infra_observability`). `registry` (+15% grow speed for Rust/Compiler/Kernel, `economy.REGISTRY_BOOST_FACTOR`, wired into `grow_seconds_for`'s new `registry_boost` bool param and into water's bonus-seconds calc). `canary` (`store/infrastructure.py::roll_canary`, a plain `random.random()` roll per harvest - deliberately NOT the deterministic `is_golden` hash, since canary is a fresh roll every harvest, not a per-planting property - `CANARY_DOUBLE_CHANCE` 12% to double, `CANARY_FAIL_CHANCE` 6% to only refund the planting cost). `observability` (raises the steal-fraction floor from 0.10 to `OBSERVABILITY_STEAL_FLOOR` 0.30 for this owner; the original critique's "see raid attempts in real time" was dropped - no scouting/detection mechanic exists anywhere in the game, and this achieves the same "raids feel less brutal" goal without inventing one).
- **Defense** (`POST /game/defense/upgrade`, `store.upgrade_defense`/`charge_upkeep`): a leveled building (`economy.DEFENSE_TIERS`, new columns `defense_level`/`defense_last_upkeep_at`) that lowers the steal-fraction floor and raises steal grace per tier (`effective_steal_fraction`/`effective_steal_grace` both gained a `floor`/`extra_seconds` parameter, defaulting to today's values, combined additively with the pre-existing Legacy `defense_level`). Upkeep is the deliberate whale sink: `economy.daily_upkeep(tier, coins) = max(tier.upkeep_daily, coins * UPKEEP_WEALTH_PCT)` (0.2%/day) so a large balance pays real money, not a trivial flat fee. `charge_upkeep` runs **lazily inside `serialize_farm`**, in the exact same spot and spirit as `_auto_harvest` (no new tick, no background service): unpaid upkeep (past `UPKEEP_GRACE_DAYS`) decays the tier by one level instead of going negative.
- **Cosmetics** (`POST /game/cosmetics/{buy,equip}`, `store/cosmetics.py`, new table `game_cosmetics` since this catalog is meant to grow - unlike the fixed Infrastructure/Defense tiers): pure-status titles and plot skins (`economy.COSMETICS`), zero gameplay effect. `game_farms.active_title` holds the equipped title key; `economy.cosmetic_title_name(key)` resolves it to a display name everywhere a leaderboard entry surfaces `title` (never render the raw key). Title display is scoped to the Code Farm leaderboard/farm view only - it does not touch `_avatar_link.html` or any sitewide identity surface.
### 3. Mastery track and new crop families (`economy.py` prestige/mastery/crops sections + `store/mastery.py`)
Orthogonal to prestige, modeled directly on the existing Legacy shape (leveled, its own currency, survives prestige). **Two-counter design, load-bearing:** `mastery_points` (spendable, decreases on spend) vs `mastery_points_earned_total` (never decreases) - crop unlocks gate on the earned total so spending points can never re-lock content. `economy.mastery_points_awarded(old_prestige, new_prestige)` awards the FIRST Mastery point the moment prestige crosses `MASTERY_UNLOCK_PRESTIGE` (50) and one more every `MASTERY_PRESTIGE_STEP` (10) thereafter (`_mastery_total_for(p) = 1 + (p-50)//10` for `p>=50`, difference of the before/after totals - correct even if prestige ever jumps by more than 1). `store/actions.py::prestige()` adds the award into the same reset dict, alongside `stars`, as a field that is **not** zeroed. `POST /game/mastery` (`store.upgrade_mastery`) spends `mastery_points` on `economy.MASTERY_UPGRADES`: `autoreplant` (Continuous Delivery - wired via a shared `_try_autoreplant` helper called from both `harvest()` and `_auto_harvest()` after crediting, replants the same crop immediately if still affordable/unlocked), `analytics` (Farm Analytics - `GameFarmOut.mastery_analytics_unlocked` gates the `lifetime_coins_earned`/`lifetime_harvests` stats panel, rendered by `_game_analytics.html`/`data-analytics-host` inside the Mastery section - both counters are accumulated by `credit_farm`, so they are exact from the moment the upgrade is bought, not backfilled for prior activity), `contracts` (Legacy Contracts - unlocks the weekly contract slot, see section 6; this deliberately consolidates the critique's "Legacy Contracts" and "weekly contracts" into ONE mechanism rather than two redundant long-goal systems).
Three new high-tier crops (`distsys`/`mlpipe`/`secfort` in `economy.CROPS`) extend the `Crop` dataclass with three new **trailing, defaulted** fields (`min_mastery: int = 0`, `steal_immune: bool = False`, `era_key: str | None = None`) - safe because every existing `Crop(...)` construction is positional over the original 8 non-defaulted fields. They set `min_level=MAX_LEVEL` (so, like every other crop, they re-lock after each refactor until the player re-levels to 20 - consistent with how Kernel etc. already behave post-prestige) and `min_mastery=1` (gated on `mastery_points_earned_total`, checked both in `store/actions.py::plant()` - real enforcement - and in `economy.crop_payload`'s `locked` flag - display). `secfort` (Security Fortress) is `steal_immune=True`: `serialize_plot` forces `can_steal=False`/`steal_reason="immune"` and `store/actions.py::steal()` raises immediately, before any grace/cooldown check, when the target crop is immune.
### 4. Secondary leaderboards (`economy.py` scoring + `store/farm.py`)
`GET /game/leaderboard` gained an optional `board` query param (default `"score"`, so every existing caller/Devii/docs entry that omits it sees byte-identical output to before this shipped). `store/farm.py::leaderboard_for(board, limit)` dispatches through `LEADERBOARD_BOARDS` (`score` = the original `farm_score` board, `prestige`, `harvests` = this week's `harvests_week` counter, `raids` = average coins per **successful** raid with a minimum of `MIN_RAIDS_FOR_EFFICIENCY_BOARD` (3) qualifying raids - deliberately not "attempts," since failed steals are never persisted today and adding that write would be a spam vector for no real value, `time_to_kernel` = seconds between a farm's last `prestiged_at` and its next Kernel harvest, `fair_play` = `economy.fair_play_score(harvests_week, coins)` which rewards recent activity and penalizes hoarding) plus `era` (dispatches to `store/era.py::leaderboard_era`, always empty when no Era is running). `GameFarm.js._loadLeaderboard` renders a board-appropriate value per row instead of always showing the raw composite score - `raid_avg` (formatted `"Nc/raid"`) for the `raids` board and a formatted duration for `time_to_kernel`, falling back to `score` for every other board. New `game_farms` columns backing these: `harvests_week`/`harvests_week_start` (reset lazily in `serialize_farm` via `_reset_harvests_week` whenever the current ISO week - `common._iso_week` - differs from the stored one, same lazy-no-tick idiom as everything else), `prestiged_at` (stamped every `prestige()`), `last_kernel_harvest_prestige`/`time_to_kernel_seconds` (updated in `harvest`/`_auto_harvest` the first time a Kernel is harvested since the last refactor).
### 5. Eras / seasons (`economy.py` era scoring + `store/era.py`, `routers/admin/game.py`)
**Admin-triggered, not automatic** - a deliberate deviation from "reset every 4-6 weeks": an unattended live-leaderboard reset in production is exactly the kind of surprising action this project treats with caution, so `POST /admin/game/era/start` and `/era/end` (`/admin/game` page, `require_admin`) are the only way an Era starts or ends. Two new tables: `game_eras` (at most one row `active=1` at a time - **zero active rows is the permanent no-op state**, everything below is dormant until the first Era is started) and `game_era_results` (the permanent per-user historical record written once at era-end, before counters reset). Three new `game_farms` columns, `era_coins`/`era_harvests`/`era_joined_at`, are a **shadow, resettable counter** completely separate from the real, permanent `coins`/`total_harvests` - `start_era` bulk-resets only these three to zero/now for every farm; `end_era` resets `era_coins`/`era_harvests` back to zero for every participant it ranks (in the same `_update_farm` call that awards Stars, so a farm never carries a stale Era total into the next dormant period) while never touching real balances, prestige, stars, Legacy, or Mastery. While an Era is active, `game.html`'s Era banner shows the owner's own live `era_coins`/`era_harvests` (via `data-era-coins`/`data-era-harvests`, kept current by `GameFarm.js._renderHud` on every action), not just the fact that a season is running. `economy.era_score(era_coins, era_harvests, prestige)` is a DISTINCT scoring function from `economy.farm_score` - it gives prestige only `ERA_PRESTIGE_CARRYOVER_PCT` (40%) weight so veterans keep an edge on the Era board specifically without it being insurmountable, while the permanent leaderboards (section 4) keep ranking by full lifetime stats untouched. `end_era` ranks every farm with any Era activity, awards `economy.ERA_REWARD_STARS_BY_RANK` Stars to the top 10 (added to the permanent `stars`, exactly like a refactor's `stars_for_refactor`) and, when the active Era name matches a `Cosmetic.era_key`, grants an Era-exclusive cosmetic. Era-exclusive crops reuse the `Crop.era_key` field from section 3 (`unlocked_crops`/`crop_payload` hide them unless the active Era's name matches - a no-op for every crop shipped so far, all of which have `era_key=None`).
### 6. Engagement: Underdog raids, weekly Contracts, client-side ready pings
**Underdog** (`economy.UNDERDOG_COIN_RATIO`=10, `UNDERDOG_DURATION_HOURS`=24, `UNDERDOG_MULTIPLIER`=1.25): in `store/actions.py::steal()`, if the owner's coins exceed 10x the thief's, the thief's `underdog_boost_until` is stamped; `_harvest_crop` applies the +25% multiplier while that timestamp is in the future. `GameFarmOut.underdog_boost_seconds_remaining` drives a HUD banner; `steal()`'s result carries `underdog_triggered` so `routers/game/farm.py::steal_farm` can fire the **David vs Goliath** badge.
**Weekly Contracts** extend the existing daily-quest engine (`game_quests` table) rather than building a second system: a new `scope` column (`"daily"`/`"weekly"`, existing/new daily rows always `"daily"` - backfilled on migration via `UPDATE game_quests SET scope='daily' WHERE scope IS NULL OR scope=''` so in-flight quests from before this shipped are never duplicated) and `reward_stars`. `store/quests.py::ensure_weekly_contract` materializes one `economy.weekly_contract(user_uid, iso_week)` row per ISO week (deterministic sha256 pick, same idiom as `daily_quests`), gated on owning the `mastery_contracts` upgrade; `advance_quests` advances both the day's rows and the current week's row in one pass. `claim_quest(user, kind, scope="daily")` claims either; a weekly claim pays Stars plus a `contract_boost_until` timestamp that grants `WEEKLY_CONTRACT_BOOST_MULTIPLIER` (1.2x) coins for `WEEKLY_CONTRACT_BOOST_HOURS` (48h), applied in `_harvest_crop` the same way as the Underdog boost.
**"Crops ready" notifications are client-side only** (`GameFarm.js`'s existing 1-second ticker fires a toast/desktop `Notification` when a countdown hits zero while the tab is open) - this respects the hard "no background tick" architectural invariant, since nothing server-side needs to detect the transition. **"Someone is scouting you" was not built** - like Observability's real-time alert, it would require inventing a scouting/detection mechanic that does not exist. **Alliance/Farm Coop was deliberately deferred** to its own follow-up plan - it is a new multiplayer social feature (group membership, shared resource pooling, its own invite/leave/kick UX), qualitatively different from every economy-tuning change here, and was judged to deserve its own design pass rather than being folded into this rebalance.
### Refactor fee, carry-over, treasury, and community grant (`store/treasury.py`)
Refactoring is no longer free at level 10 - it is the economy's progressive wealth sink, and the sink funds a redistribution loop. All backwards compatible: new `game_farms` columns (`legacy_carryover`, `last_grant_week`) default NULL and are `COALESCE`d, and the `game_treasury` table starts empty (lazily seeded by `ensure_treasury`).
- **Dynamic fee** (`economy.refactor_cost(prestige, coins)` = `REFACTOR_BASE_COST` (20k) `* (1 + prestige) * prestige_multiplier(prestige) + REFACTOR_WEALTH_PCT` (15%) `* coins`): the polynomial prestige term makes time-to-afford grow linearly with prestige (a soft cap on prestige cycling - income grows only linearly, so an exponential fee would wall off Mastery), and the wealth term is a progressive tax a hoarder cannot dodge. At prestige 0 the fee means a level-10 farm must keep farming (~23.5k coins minimum) before its first refactor - instant level-10 cycling is dead.
- **Carry-over** (`economy.refactor_carryover(coins, fee, carryover_level)`): after the fee, `REFACTOR_CARRYOVER_BASE` (10%) of the remainder survives the reset, raised +5%/level by the new **Golden Parachute** Legacy upgrade (`legacy_carryover`, max 5 -> 35%). Always strictly below 100% so a refactor can never be net-profitable (same invariant class as the fertilize fix). `prestige()` pins the balance in the atomic WHERE (`AND coins = :coins_now`) so a concurrent harvest yields a "balance just changed - refresh" `GameError` instead of a wrong charge, then credits the fee to the treasury.
- **Treasury** (`game_treasury`, single row `uid="treasury-main"`, `balance`/`collected_total`/`granted_total`): every refactor fee is credited atomically (`credit_treasury`, raw conditional SQL like `conditional_update_farm`). The ledger invariant `balance == collected_total - granted_total` is fuzz-verified.
- **Community grant** (`POST /game/grant`, `store.claim_grant`): once per ISO week (`last_grant_week`), an **active low-wealth, low-prestige** farm (coins < `GRANT_WEALTH_CEILING` 10k, `harvests_week >= GRANT_MIN_WEEK_HARVESTS` 5 - the activity gate blocks idle alts, prestige <= `GRANT_MAX_PRESTIGE` 5 - so post-refactor whales with a briefly low balance never draw welfare) claims `economy.grant_amount(balance)` (capped `GRANT_CAP` 2,500). Order of operations: atomic treasury debit first (`WHERE balance >= :amount`), then the atomic farm credit+week-stamp; a losing farm update refunds the treasury. Race-verified with real OS processes: concurrent claims by one farm pay exactly once, and two farms racing an underfunded treasury never overdraw it.
- **Fan-out:** `serialize_farm` exposes `refactor_cost`/`refactor_affordable`/`refactor_carryover_pct`/`refactor_carryover_preview` plus the owner-only `grant_*`/`treasury_balance`; the shop row and `GameFarm.js._shopHtml` show the fee and hide the button until affordable; the sidebar **Community grant** section is `_game_grant.html`/`data-grant-host`/`_grantHtml`. Devii: `game_prestige` is now in `CONFIRM_REQUIRED` (large irreversible spend) with a declared `confirm` param; `game_claim_grant` is a plain auth tool. Docs: `game-prestige` updated + `game-grant` added.
### Admin surface
`/admin/game` (`routers/admin/game.py`, `admin_game.html`, sidebar entry between Bot Monitor and AI usage) shows the current Era status and the start/end forms; `AdminGameOut` is the response schema. `devplace game market prune` and `devplace game era {status,start,end}` mirror the same actions from the CLI (`cli/game.py`).

View File

@ -35,9 +35,6 @@ class Crop:
reward_coins: int
reward_xp: int
min_level: int
min_mastery: int = 0
steal_immune: bool = False
era_key: str | None = None
CROPS: tuple[Crop, ...] = (
@ -48,14 +45,9 @@ CROPS: tuple[Crop, ...] = (
Crop("rust", "Rust Engine", "\U0001f980", 200, 1800, 520, 60, 4),
Crop("haskell", "Compiler", "λ", 500, 3600, 1380, 150, 6),
Crop("kernel", "Kernel", "⚙️", 1200, 7200, 3600, 400, 8),
Crop("distsys", "Distributed System", "\U0001f578", 5000, 14400, 9500, 900, MAX_LEVEL, min_mastery=1),
Crop("mlpipe", "ML Pipeline", "\U0001f9e0", 12000, 21600, 21000, 1800, MAX_LEVEL, min_mastery=1),
Crop("secfort", "Security Fortress", "\U0001f510", 30000, 28800, 48000, 3200, MAX_LEVEL, min_mastery=1, steal_immune=True),
)
CROP_BY_KEY = {crop.key: crop for crop in CROPS}
MARKET_TRACKED_CROPS = ("shell", "python", "webapp", "api", "rust", "haskell", "kernel")
REGISTRY_BOOST_CROPS = ("rust", "haskell", "kernel")
@dataclass(frozen=True)
@ -91,9 +83,6 @@ def next_ci_tier(tier: int) -> CiTier | None:
return CI_BY_TIER.get(tier + 1)
REGISTRY_BOOST_FACTOR = 1.15
def farm_speed(ci_tier: int, growth_level: int = 0, legacy_speed_level: int = 0) -> float:
return (
ci_speed(ci_tier)
@ -103,16 +92,11 @@ def farm_speed(ci_tier: int, growth_level: int = 0, legacy_speed_level: int = 0)
def grow_seconds_for(
crop: Crop,
ci_tier: int,
growth_level: int = 0,
legacy_speed_level: int = 0,
registry_boost: bool = False,
crop: Crop, ci_tier: int, growth_level: int = 0, legacy_speed_level: int = 0
) -> int:
speed = farm_speed(ci_tier, growth_level, legacy_speed_level)
if registry_boost and crop.key in REGISTRY_BOOST_CROPS:
speed *= REGISTRY_BOOST_FACTOR
return max(1, round(crop.grow_seconds / speed))
return max(
1, round(crop.grow_seconds / farm_speed(ci_tier, growth_level, legacy_speed_level))
)
def water_bonus_seconds(
@ -194,16 +178,8 @@ def farm_score(farm: dict) -> int:
)
def unlocked_crops(
level: int, mastery_earned: int = 0, active_era_name: str | None = None
) -> list[Crop]:
return [
crop
for crop in CROPS
if crop.min_level <= level
and crop.min_mastery <= mastery_earned
and (crop.era_key is None or crop.era_key == active_era_name)
]
def unlocked_crops(level: int) -> list[Crop]:
return [crop for crop in CROPS if crop.min_level <= level]
def crop_payload(
@ -216,32 +192,19 @@ def crop_payload(
prestige: int = 0,
legacy_mult_level: int = 0,
legacy_speed_level: int = 0,
market_factor: float = 1.0,
mastery_earned: int = 0,
active_era_name: str | None = None,
registry_boost: bool = False,
) -> dict:
era_locked = crop.era_key is not None and crop.era_key != active_era_name
market_state = "normal"
if market_factor < 1.0:
market_state = "saturated"
elif market_factor > 1.0:
market_state = "boosted"
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, legacy_mult_level, market_factor
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, legacy_speed_level, registry_boost
),
"locked": crop.min_level > level or crop.min_mastery > mastery_earned or era_locked,
"market_state": market_state,
"grow_seconds": grow_seconds_for(crop, ci_tier, growth_level, legacy_speed_level),
"locked": crop.min_level > level,
}
@ -269,12 +232,6 @@ PERK_BY_KEY = {perk.key: perk for perk in PERKS}
PRESTIGE_MIN_LEVEL = 10
PRESTIGE_BONUS = 0.25
REFACTOR_BASE_COST = 20_000
REFACTOR_WEALTH_PCT = 0.15
REFACTOR_CARRYOVER_BASE = 0.10
REFACTOR_CARRYOVER_MAX = 0.95
LEGACY_CARRYOVER_STEP = 0.05
STAR_BASE = 1
LEGACY_SPEED_STEP = 0.05
LEGACY_MULT_STEP = 0.10
@ -327,15 +284,6 @@ LEGACY_UPGRADES: tuple[LegacyUpgrade, ...] = (
2,
1.8,
),
LegacyUpgrade(
"carryover",
"Golden Parachute",
"🪂",
"+5% of your post-fee coins carried through each refactor per level",
5,
2,
1.8,
),
)
LEGACY_BY_KEY = {up.key: up for up in LEGACY_UPGRADES}
@ -357,12 +305,12 @@ 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, extra_seconds: int = 0) -> int:
return STEAL_GRACE_SECONDS + LEGACY_DEFENSE_GRACE * max(0, defense_level) + max(0, extra_seconds)
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, floor: float = 0.1) -> float:
return max(floor, STEAL_FRACTION - LEGACY_DEFENSE_FRACTION * 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:
@ -378,9 +326,6 @@ def legacy_value_text(up: LegacyUpgrade, level: int) -> str:
return f"+{round(LEGACY_SPEED_STEP * level * 100)}% build speed"
if up.key == "plots":
return f"+{level} starting plots"
if up.key == "carryover":
pct = round(refactor_carryover_fraction(level) * 100)
return f"{pct}% refactor carry-over"
grace = LEGACY_DEFENSE_GRACE * level
loss = round(LEGACY_DEFENSE_FRACTION * level * 100)
return f"+{grace}s grace, -{loss}% steal loss"
@ -423,59 +368,19 @@ def prestige_multiplier(prestige: int) -> float:
return 1 + PRESTIGE_BONUS * max(0, prestige)
def refactor_cost(prestige: int, coins: int) -> int:
scaled = REFACTOR_BASE_COST * (1 + max(0, prestige)) * prestige_multiplier(prestige)
return round(scaled + REFACTOR_WEALTH_PCT * max(0, coins))
def refactor_carryover_fraction(carryover_level: int = 0) -> float:
return min(
REFACTOR_CARRYOVER_MAX,
REFACTOR_CARRYOVER_BASE + LEGACY_CARRYOVER_STEP * max(0, carryover_level),
)
def refactor_carryover(coins: int, fee: int, carryover_level: int = 0) -> int:
remainder = max(0, coins - fee)
return int(remainder * refactor_carryover_fraction(carryover_level))
GRANT_WEALTH_CEILING = 10_000
GRANT_MIN_WEEK_HARVESTS = 5
GRANT_MAX_PRESTIGE = 5
GRANT_CAP = 2_500
def grant_amount(treasury_balance: int) -> int:
return max(0, min(GRANT_CAP, treasury_balance))
UNDERDOG_COIN_RATIO = 10
UNDERDOG_DURATION_HOURS = 24
UNDERDOG_MULTIPLIER = 1.25
def effective_plant_cost(crop: Crop, discount_level: int = 0) -> int:
factor = max(0.0, 1 - PERK_BY_KEY["discount"].step * discount_level)
return max(1, round(crop.cost * factor))
def effective_reward_coins(
crop: Crop,
yield_level: int = 0,
prestige: int = 0,
legacy_mult_level: int = 0,
market_factor: float = 1.0,
underdog: bool = False,
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)
* market_factor
)
if underdog:
factor *= UNDERDOG_MULTIPLIER
return round(crop.reward_coins * factor)
@ -489,14 +394,12 @@ def steal_reward_coins(
prestige: int = 0,
legacy_mult_level: int = 0,
defense_level: int = 0,
market_factor: float = 1.0,
steal_floor: float = 0.1,
) -> int:
fraction = effective_steal_fraction(defense_level, steal_floor)
fraction = effective_steal_fraction(defense_level)
return max(
1,
round(
effective_reward_coins(crop, yield_level, prestige, legacy_mult_level, market_factor)
effective_reward_coins(crop, yield_level, prestige, legacy_mult_level)
* fraction
),
)
@ -569,256 +472,3 @@ def daily_quests(user_uid: str, day: str) -> list[dict]:
}
)
return quests
WEEKLY_CONTRACT_STAR_DIVISOR = 40
WEEKLY_CONTRACT_XP_DIVISOR = 4
WEEKLY_CONTRACT_BOOST_MULTIPLIER = 1.2
WEEKLY_CONTRACT_BOOST_HOURS = 48
def weekly_contract(user_uid: str, iso_week: str) -> dict:
seed = int(hashlib.sha256(f"{user_uid}:{iso_week}".encode()).hexdigest(), 16)
kind = QUEST_KINDS[seed % len(QUEST_KINDS)]
definition = QUEST_DEFS[kind]
goal = (definition.goal_max + definition.goal_min) * 4
if kind == "earn":
goal = max(definition.goal_min, (goal // 10) * 10)
reward_stars = max(1, goal // WEEKLY_CONTRACT_STAR_DIVISOR)
reward_xp = max(1, goal // WEEKLY_CONTRACT_XP_DIVISOR)
return {
"kind": kind,
"label": f"Weekly: {definition.label.format(goal=goal)}",
"goal": goal,
"reward_stars": reward_stars,
"reward_xp": reward_xp,
}
MARKET_WINDOW_HOURS = 48
MARKET_SATURATION_TIERS: tuple[tuple[int, float], ...] = (
(0, 1.00),
(40, 0.85),
(120, 0.70),
(300, 0.55),
(600, 0.40),
)
MARKET_BUFFED_CROPS = ("shell", "python", "webapp", "api")
MARKET_BUFF_CAP = 1.15
def market_saturation_factor(recent_harvests: int) -> float:
factor = MARKET_SATURATION_TIERS[0][1]
for threshold, tier_factor in MARKET_SATURATION_TIERS:
if recent_harvests >= threshold:
factor = tier_factor
return factor
def market_buff_factor(crop_key: str, saturation_factor: float) -> float:
if crop_key not in MARKET_BUFFED_CROPS:
return 1.0
relief = 1.0 - saturation_factor
return min(MARKET_BUFF_CAP, 1.0 + relief)
@dataclass(frozen=True)
class Infrastructure:
key: str
name: str
icon: str
description: str
cost: int
min_prestige: int
INFRASTRUCTURE: tuple[Infrastructure, ...] = (
Infrastructure(
"registry",
"Private Registry",
"\U0001f4e6",
"Rust, Compiler, and Kernel crops grow 15% faster",
25_000_000,
3,
),
Infrastructure(
"canary",
"Canary Deployments",
"\U0001f424",
"Every harvest has a 12% chance to double and a 6% chance to only refund its planting cost",
75_000_000,
8,
),
Infrastructure(
"observability",
"Observability Suite",
"\U0001f52d",
"Raises the minimum coins you keep when raided from 10% to 30%",
150_000_000,
15,
),
)
INFRA_BY_KEY = {i.key: i for i in INFRASTRUCTURE}
CANARY_DOUBLE_CHANCE = 0.12
CANARY_FAIL_CHANCE = 0.06
OBSERVABILITY_STEAL_FLOOR = 0.3
def infra_for(key: str) -> Infrastructure | None:
return INFRA_BY_KEY.get(key)
@dataclass(frozen=True)
class DefenseTier:
level: int
name: str
upgrade_cost: int
upkeep_daily: int
steal_fraction_floor: float
grace_bonus: int
DEFENSE_TIERS: tuple[DefenseTier, ...] = (
DefenseTier(0, "Undefended", 0, 0, 0.10, 0),
DefenseTier(1, "Firewall", 5_000, 500, 0.10, 15),
DefenseTier(2, "WAF", 40_000, 2_500, 0.08, 30),
DefenseTier(3, "SOC Monitoring", 300_000, 15_000, 0.06, 60),
DefenseTier(4, "Zero Trust Mesh", 2_000_000, 100_000, 0.04, 120),
)
MAX_DEFENSE_LEVEL = DEFENSE_TIERS[-1].level
DEFENSE_BY_LEVEL = {tier.level: tier for tier in DEFENSE_TIERS}
UPKEEP_WEALTH_PCT = 0.002
UPKEEP_GRACE_DAYS = 2
def defense_tier(level: int) -> DefenseTier:
return DEFENSE_BY_LEVEL.get(max(0, level), DEFENSE_TIERS[0])
def next_defense_tier(level: int) -> DefenseTier | None:
return DEFENSE_BY_LEVEL.get(level + 1)
def daily_upkeep(tier: DefenseTier, coins: int) -> int:
return max(tier.upkeep_daily, round(coins * UPKEEP_WEALTH_PCT))
@dataclass(frozen=True)
class Cosmetic:
key: str
name: str
icon: str
description: str
cost_coins: int
kind: str
era_key: str | None = None
COSMETICS: tuple[Cosmetic, ...] = (
Cosmetic("title_architect", "The Architect", "\U0001f3db", "A permanent title shown on the leaderboard", 500_000, "title"),
Cosmetic("title_refactorer", "Serial Refactorer", "", "A permanent title for the serially prestiged", 250_000, "title"),
Cosmetic("title_kernel_hacker", "Kernel Hacker", "", "A permanent title for Kernel harvesters", 1_000_000, "title"),
Cosmetic("skin_neon", "Neon Terminal", "\U0001f308", "A cosmetic plot skin, no gameplay effect", 750_000, "skin"),
)
COSMETIC_BY_KEY = {c.key: c for c in COSMETICS}
def cosmetic_for(key: str) -> Cosmetic | None:
return COSMETIC_BY_KEY.get(key)
def cosmetic_title_name(key: str) -> str:
cosmetic = COSMETIC_BY_KEY.get(key or "")
return cosmetic.name if cosmetic and cosmetic.kind == "title" else ""
MASTERY_UNLOCK_PRESTIGE = 50
MASTERY_PRESTIGE_STEP = 10
def _mastery_total_for(prestige: int) -> int:
if prestige < MASTERY_UNLOCK_PRESTIGE:
return 0
return 1 + (prestige - MASTERY_UNLOCK_PRESTIGE) // MASTERY_PRESTIGE_STEP
def mastery_points_awarded(old_prestige: int, new_prestige: int) -> int:
return max(0, _mastery_total_for(new_prestige) - _mastery_total_for(old_prestige))
@dataclass(frozen=True)
class MasteryUpgrade:
key: str
name: str
icon: str
description: str
max_level: int
base_cost: int
cost_growth: float
MASTERY_UPGRADES: tuple[MasteryUpgrade, ...] = (
MasteryUpgrade(
"autoreplant",
"Continuous Delivery",
"\U0001f501",
"Automatically replant the same crop right after harvest, if affordable",
1,
3,
1.0,
),
MasteryUpgrade(
"analytics",
"Farm Analytics",
"\U0001f4ca",
"Unlocks lifetime stats on your farm HUD",
1,
2,
1.0,
),
MasteryUpgrade(
"contracts",
"Legacy Contracts",
"\U0001f4dc",
"Unlocks a weekly long-term contract slot for Stars and a temporary boost",
1,
4,
1.0,
),
)
MASTERY_BY_KEY = {m.key: m for m in MASTERY_UPGRADES}
def mastery_for(key: str) -> MasteryUpgrade | None:
return MASTERY_BY_KEY.get(key)
def mastery_cost(m: MasteryUpgrade, level: int) -> int:
return round(m.base_cost * (m.cost_growth ** level))
FAIR_PLAY_ACTIVITY_WEIGHT = 50
FAIR_PLAY_HOARD_DIVISOR = 200_000
MIN_RAIDS_FOR_EFFICIENCY_BOARD = 3
def fair_play_score(harvests_week: int, coins: int) -> int:
return harvests_week * FAIR_PLAY_ACTIVITY_WEIGHT - min(coins, 10**9) // FAIR_PLAY_HOARD_DIVISOR
ERA_PRESTIGE_CARRYOVER_PCT = 0.4
ERA_REWARD_STARS_BY_RANK: tuple[int, ...] = (50, 30, 20, 15, 10, 5, 5, 5, 5, 5)
def era_score(era_coins: int, era_harvests: int, prestige: int) -> int:
return (
era_coins // 20
+ era_harvests * 10
+ round(prestige * SCORE_PRESTIGE * ERA_PRESTIGE_CARRYOVER_PCT)
)
def era_reward_stars(rank: int) -> int:
if rank < 1 or rank > len(ERA_REWARD_STARS_BY_RANK):
return 0
return ERA_REWARD_STARS_BY_RANK[rank - 1]

View File

@ -27,7 +27,6 @@ from .common import (
PERK_COLUMN,
_farms,
_iso,
_iso_week,
_lvl,
_now,
_parse,
@ -37,13 +36,9 @@ from .common import (
_steals,
_today,
_update_farm,
credit_farm,
last_steal_at,
steal_cooldown_remaining,
)
from .cosmetics import buy_cosmetic, equip_title, owned_cosmetic_keys
from .defense import charge_upkeep, upgrade_defense
from .era import active_era, active_era_name, end_era, start_era
from .farm import (
_create_plot,
_plot_at,
@ -52,14 +47,9 @@ from .farm import (
get_farm,
get_plots,
leaderboard,
leaderboard_for,
upgrade_ci,
)
from .infrastructure import buy_infrastructure, owns_infrastructure
from .market import market_factor_for, prune_ticks, recent_harvests
from .mastery import upgrade_mastery
from .quests import advance_quests, ensure_quests
from .treasury import claim_grant, ensure_treasury, grant_status, treasury_balance
from .serialize import (
_daily_available,
_plot_state,

View File

@ -11,10 +11,7 @@ from .. import economy
from .common import (
GameError,
PERK_COLUMN,
conditional_update_farm,
credit_farm,
_iso,
_iso_week,
_lvl,
_now,
_parse,
@ -26,11 +23,7 @@ from .common import (
_update_farm,
steal_cooldown_remaining,
)
from .era import active_era_name
from .farm import _create_plot, _plot_at, ensure_farm, get_farm, get_plots
from .infrastructure import owns_infrastructure, roll_canary
from .market import market_factor_for, record_harvest_tick
from .mastery import MASTERY_COLUMN
from .quests import advance_quests, ensure_quests
from .serialize import _daily_available, _plot_state, _watered_by
@ -43,10 +36,6 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
progress = economy.level_progress(int(farm.get("xp", 0)))
if crop.min_level > progress["level"]:
raise GameError(f"{crop.name} unlocks at level {crop.min_level}.")
if crop.min_mastery > _lvl(farm, "mastery_points_earned_total"):
raise GameError(f"{crop.name} unlocks after reaching Mastery.")
if crop.era_key and crop.era_key != active_era_name():
raise GameError(f"{crop.name} is not available right now.")
plot = _plot_at(farm["uid"], slot)
if not plot:
raise GameError("That plot does not exist.")
@ -59,11 +48,7 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
now = _now()
ci_tier = int(farm.get("ci_tier", 1))
grow = economy.grow_seconds_for(
crop,
ci_tier,
_lvl(farm, "perk_growth"),
_lvl(farm, "legacy_speed"),
owns_infrastructure(farm, "registry"),
crop, ci_tier, _lvl(farm, "perk_growth"), _lvl(farm, "legacy_speed")
)
ready_at = now + timedelta(seconds=grow)
_plots().update(
@ -82,34 +67,6 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
return {"slot": slot, "crop": crop.key, "spent": cost}
def _harvest_crop(farm: dict, crop, plot_uid: str, planted_at: str, now) -> dict:
prestige = _lvl(farm, "prestige")
golden = economy.is_golden(plot_uid, planted_at)
market_factor = market_factor_for(crop.key)
underdog = bool(farm.get("underdog_boost_until")) and now < (
_parse(farm.get("underdog_boost_until") or "") or now
)
coins_gain = economy.effective_reward_coins(
crop,
_lvl(farm, "perk_yield"),
prestige,
_lvl(farm, "legacy_multiplier"),
market_factor,
underdog,
)
if golden:
coins_gain *= economy.GOLDEN_MULTIPLIER
contract_boost_until = _parse(farm.get("contract_boost_until") or "")
if contract_boost_until and now < contract_boost_until:
coins_gain = round(coins_gain * economy.WEEKLY_CONTRACT_BOOST_MULTIPLIER)
if owns_infrastructure(farm, "canary"):
plant_cost = economy.effective_plant_cost(crop, _lvl(farm, "perk_discount"))
coins_gain = roll_canary(coins_gain, plant_cost)
xp_gain = economy.effective_reward_xp(crop, _lvl(farm, "perk_xp"))
record_harvest_tick(crop.key, now)
return {"coins": coins_gain, "xp": xp_gain, "golden": golden}
def harvest(user: dict, slot: int) -> dict:
farm = ensure_farm(user["uid"])
plot = _plot_at(farm["uid"], slot)
@ -121,7 +78,6 @@ def harvest(user: dict, slot: int) -> dict:
crop = economy.crop_for(plot.get("crop_key", ""))
if not crop:
raise GameError("Unknown crop type.")
planted_at = plot.get("planted_at", "")
_plots().update(
{
"uid": plot["uid"],
@ -133,26 +89,26 @@ def harvest(user: dict, slot: int) -> dict:
},
["uid"],
)
result = _harvest_crop(farm, crop, plot.get("uid", ""), planted_at, now)
coins_gain, xp_gain, golden = result["coins"], result["xp"], result["golden"]
extra = {}
if crop.key == "kernel" and _lvl(farm, "last_kernel_harvest_prestige") != _lvl(farm, "prestige"):
prestiged_at = _parse(farm.get("prestiged_at") or "")
if prestiged_at:
extra["time_to_kernel_seconds"] = max(0, int((now - prestiged_at).total_seconds()))
extra["last_kernel_harvest_prestige"] = _lvl(farm, "prestige")
farm = credit_farm(
farm,
coins=coins_gain,
xp=xp_gain,
harvests=1,
era_active=bool(active_era_name()),
extra=extra or None,
prestige = _lvl(farm, "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(
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)) + 1,
},
)
advance_quests(user["uid"], "harvest", 1)
advance_quests(user["uid"], "earn", coins_gain)
if _lvl(farm, MASTERY_COLUMN["autoreplant"]):
_try_autoreplant(farm, slot, crop, now)
return {
"slot": slot,
"crop": crop.key,
@ -162,35 +118,6 @@ def harvest(user: dict, slot: int) -> dict:
}
def _try_autoreplant(farm: dict, slot: int, crop, now) -> None:
plot = _plot_at(farm["uid"], slot)
if not plot or plot.get("crop_key"):
return
cost = economy.effective_plant_cost(crop, _lvl(farm, "perk_discount"))
if int(farm.get("coins", 0)) < cost:
return
grow = economy.grow_seconds_for(
crop,
int(farm.get("ci_tier", 1)),
_lvl(farm, "perk_growth"),
_lvl(farm, "legacy_speed"),
owns_infrastructure(farm, "registry"),
)
ready_at = now + timedelta(seconds=grow)
_plots().update(
{
"uid": plot["uid"],
"crop_key": crop.key,
"planted_at": _iso(now),
"ready_at": _iso(ready_at),
"watered_by": "[]",
"updated_at": _iso(now),
},
["uid"],
)
_update_farm(farm["uid"], {"coins": int(farm.get("coins", 0)) - cost})
def water(visitor: dict, owner: dict, slot: int) -> dict:
if visitor["uid"] == owner["uid"]:
raise GameError("You cannot water your own build.")
@ -211,8 +138,6 @@ def water(visitor: dict, owner: dict, slot: int) -> dict:
bonus = economy.water_bonus_seconds(
crop, int(farm.get("ci_tier", 1)), _lvl(farm, "perk_growth"), _lvl(farm, "legacy_speed")
)
if owns_infrastructure(farm, "registry") and crop.key in economy.REGISTRY_BOOST_CROPS:
bonus = round(bonus * economy.REGISTRY_BOOST_FACTOR)
new_ready = max(now, ready_at - timedelta(seconds=bonus))
watered.append(visitor["uid"])
_plots().update(
@ -256,16 +181,9 @@ 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.")
if crop.steal_immune:
raise GameError("That build cannot be raided.")
defense_level = _lvl(farm, "legacy_defense")
building_level = _lvl(farm, "defense_level")
building_tier = economy.defense_tier(building_level)
floor = building_tier.steal_fraction_floor
if owns_infrastructure(farm, "observability"):
floor = max(floor, economy.OBSERVABILITY_STEAL_FLOOR)
grace = economy.effective_steal_grace(defense_level, building_tier.grace_bonus)
ready_at = _parse(plot.get("ready_at", "")) or now
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)
@ -286,27 +204,17 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
},
["uid"],
)
market_factor = market_factor_for(crop.key)
coins_gain = economy.steal_reward_coins(
crop,
_lvl(farm, "perk_yield"),
_lvl(farm, "prestige"),
_lvl(farm, "legacy_multiplier"),
defense_level,
market_factor,
floor,
)
thief_farm = ensure_farm(thief["uid"])
underdog_extra = {}
if int(farm.get("coins", 0)) > int(thief_farm.get("coins", 0)) * economy.UNDERDOG_COIN_RATIO:
underdog_extra["underdog_boost_until"] = _iso(
now + timedelta(hours=economy.UNDERDOG_DURATION_HOURS)
)
credit_farm(
thief_farm,
coins=coins_gain,
era_active=bool(active_era_name()),
extra=underdog_extra or None,
_update_farm(
thief_farm["uid"],
{"coins": int(thief_farm.get("coins", 0)) + coins_gain},
)
_steals().insert(
{
@ -325,23 +233,24 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
"crop": crop.key,
"coins": coins_gain,
"owner_uid": owner["uid"],
"underdog_triggered": bool(underdog_extra),
}
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
replant_slots = []
extra = {}
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
result = _harvest_crop(farm, crop, plot.get("uid", ""), plot.get("planted_at", ""), now)
golden = economy.is_golden(plot.get("uid", ""), plot.get("planted_at", ""))
_plots().update(
{
"uid": plot["uid"],
@ -353,47 +262,34 @@ def _auto_harvest(farm: dict, owner_uid: str, now: datetime) -> dict:
},
["uid"],
)
coins_gain += result["coins"]
xp_gain += result["xp"]
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
replant_slots.append((int(plot.get("slot_index", 0)), crop))
if crop.key == "kernel" and _lvl(farm, "last_kernel_harvest_prestige") != _lvl(
farm, "prestige"
):
prestiged_at = _parse(farm.get("prestiged_at") or "")
if prestiged_at:
extra["time_to_kernel_seconds"] = max(0, int((now - prestiged_at).total_seconds()))
extra["last_kernel_harvest_prestige"] = _lvl(farm, "prestige")
if not harvested:
return farm
farm = credit_farm(
farm,
coins=coins_gain,
xp=xp_gain,
harvests=harvested,
era_active=bool(active_era_name()),
extra=extra or None,
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)
if _lvl(farm, MASTERY_COLUMN["autoreplant"]):
for slot_index, crop in replant_slots:
_try_autoreplant(farm, slot_index, crop, now)
return get_farm(owner_uid) or farm
def claim_quest(user: dict, kind: str, scope: str = "daily") -> dict:
from .quests import ensure_weekly_contract
def claim_quest(user: dict, kind: str) -> dict:
farm = ensure_farm(user["uid"])
now = _now()
if scope == "weekly":
day = _iso_week(now)
ensure_weekly_contract(farm, day)
else:
day = _today()
ensure_quests(farm, day)
row = _quests().find_one(farm_uid=farm["uid"], day=day, kind=kind, scope=scope)
day = _today()
ensure_quests(farm, day)
row = _quests().find_one(farm_uid=farm["uid"], day=day, kind=kind)
if not row:
raise GameError("No such quest today.")
if row.get("claimed"):
@ -403,23 +299,19 @@ def claim_quest(user: dict, kind: str, scope: str = "daily") -> dict:
raise GameError("Quest not complete yet.")
reward_coins = int(row.get("reward_coins") or 0)
reward_xp = int(row.get("reward_xp") or 0)
reward_stars = int(row.get("reward_stars") or 0)
_quests().update(
{"uid": row["uid"], "claimed": 1, "updated_at": _iso(now)}, ["uid"]
{"uid": row["uid"], "claimed": 1, "updated_at": _iso(_now())}, ["uid"]
)
extra = {"stars": _lvl(farm, "stars") + reward_stars} if reward_stars else None
if scope == "weekly":
extra = extra or {}
extra["contract_boost_until"] = _iso(
now + timedelta(hours=economy.WEEKLY_CONTRACT_BOOST_HOURS)
)
credit_farm(farm, coins=reward_coins, xp=reward_xp, extra=extra)
return {
"kind": kind,
"reward_coins": reward_coins,
"reward_xp": reward_xp,
"reward_stars": reward_stars,
}
new_xp = int(farm.get("xp", 0)) + reward_xp
_update_farm(
farm["uid"],
{
"coins": int(farm.get("coins", 0)) + reward_coins,
"xp": new_xp,
"level": economy.level_for_xp(new_xp),
},
)
return {"kind": kind, "reward_coins": reward_coins, "reward_xp": reward_xp}
def claim_daily(user: dict) -> dict:
@ -430,11 +322,13 @@ def claim_daily(user: dict) -> dict:
last = _parse_date(farm.get("last_daily_at") or "")
streak = _lvl(farm, "streak") + 1 if last == now.date() - timedelta(days=1) else 1
reward = economy.daily_reward(streak)
credit_farm(
farm,
coins=reward,
era_active=bool(active_era_name()),
extra={"streak": streak, "last_daily_at": _iso(now)},
_update_farm(
farm["uid"],
{
"coins": int(farm.get("coins", 0)) + reward,
"streak": streak,
"last_daily_at": _iso(now),
},
)
return {"reward": reward, "streak": streak}
@ -449,17 +343,9 @@ def upgrade_perk(user: dict, perk_key: str) -> dict:
if level >= perk.max_level:
raise GameError("That perk is maxed out.")
cost = economy.perk_cost(perk, level)
rows = conditional_update_farm(
farm["uid"],
set_clause=f"coins = coins - :cost, {column} = :new_level",
where_clause=f"COALESCE({column}, 0) = :current_level AND coins >= :cost",
params={"cost": cost, "new_level": level + 1, "current_level": level},
)
if rows == 0:
farm = get_farm(user["uid"])
if _lvl(farm, column) != level:
raise GameError("That perk already changed - refresh and try again.")
if int(farm.get("coins", 0)) < cost:
raise GameError("Not enough coins for that upgrade.")
_update_farm(farm["uid"], {"coins": int(farm.get("coins", 0)) - cost, column: level + 1})
return {"perk": perk.key, "level": level + 1, "spent": cost}
@ -473,87 +359,25 @@ def upgrade_legacy(user: dict, key: str) -> dict:
if level >= upgrade.max_level:
raise GameError("That legacy upgrade is maxed out.")
cost = economy.legacy_cost(upgrade, level)
rows = conditional_update_farm(
farm["uid"],
set_clause=f"stars = COALESCE(stars, 0) - :cost, {column} = :new_level",
where_clause=f"COALESCE({column}, 0) = :current_level AND COALESCE(stars, 0) >= :cost",
params={"cost": cost, "new_level": level + 1, "current_level": level},
)
if rows == 0:
farm = get_farm(user["uid"])
if _lvl(farm, column) != level:
raise GameError("That legacy upgrade already changed - refresh and try again.")
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:
from .treasury import credit_treasury
farm = ensure_farm(user["uid"])
level = economy.level_for_xp(int(farm.get("xp", 0)))
if level < economy.PRESTIGE_MIN_LEVEL:
raise GameError(
f"Reach level {economy.PRESTIGE_MIN_LEVEL} to refactor (prestige)."
)
old_prestige = _lvl(farm, "prestige")
new_prestige = old_prestige + 1
coins_now = int(farm.get("coins", 0))
fee = economy.refactor_cost(old_prestige, coins_now)
if coins_now < fee:
raise GameError(
f"Refactoring costs {fee} coins right now - keep farming to afford it."
)
carried = economy.refactor_carryover(coins_now, fee, _lvl(farm, "legacy_carryover"))
stars_award = economy.stars_for_refactor(level, old_prestige)
mastery_award = economy.mastery_points_awarded(old_prestige, new_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_dt = _now()
now = _iso(now_dt)
set_parts = [
"coins = :post_coins",
"xp = 0",
"level = 1",
"ci_tier = 1",
"plot_count = :base_plots",
"prestige = :new_prestige",
"stars = COALESCE(stars, 0) + :stars_award",
"mastery_points = COALESCE(mastery_points, 0) + :mastery_award",
"mastery_points_earned_total = COALESCE(mastery_points_earned_total, 0) + :mastery_award",
"prestiged_at = :now",
]
params = {
"post_coins": economy.STARTING_COINS + carried,
"base_plots": base_plots,
"new_prestige": new_prestige,
"stars_award": stars_award,
"mastery_award": mastery_award,
"now": now,
"old_prestige": old_prestige,
"coins_now": coins_now,
}
for perk in economy.PERKS:
column = PERK_COLUMN[perk.key]
set_parts.append(f"{column} = 0")
rows = conditional_update_farm(
farm["uid"],
set_clause=", ".join(set_parts),
where_clause="COALESCE(prestige, 0) = :old_prestige AND coins = :coins_now",
params=params,
)
if rows == 0:
farm = get_farm(user["uid"])
if _lvl(farm, "prestige") != old_prestige:
raise GameError("Your farm already refactored - refresh and try again.")
if int(farm.get("coins", 0)) != coins_now:
raise GameError("Your coin balance just changed - refresh and try again.")
raise GameError(
f"Reach level {economy.PRESTIGE_MIN_LEVEL} to refactor (prestige)."
)
credit_treasury(fee)
now = _iso(_now())
kept_slots = set()
for plot in get_plots(farm["uid"]):
if int(plot.get("slot_index", 0)) >= base_plots:
@ -574,13 +398,19 @@ def prestige(user: dict) -> dict:
for slot_index in range(base_plots):
if slot_index not in kept_slots:
_create_plot(farm["uid"], user["uid"], slot_index, now)
return {
reset = {
"coins": economy.STARTING_COINS,
"xp": 0,
"level": 1,
"ci_tier": 1,
"plot_count": base_plots,
"prestige": new_prestige,
"stars_awarded": stars_award,
"mastery_awarded": mastery_award,
"fee": fee,
"carried": carried,
"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, "stars_awarded": stars_award}
def fertilize(user: dict, slot: int) -> dict:
@ -600,18 +430,10 @@ def fertilize(user: dict, slot: int) -> dict:
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"),
owns_infrastructure(farm, "registry"),
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"),
market_factor_for(crop.key),
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:

View File

@ -34,12 +34,6 @@ def _today() -> str:
return _now().date().isoformat()
def _iso_week(value: datetime | None = None) -> str:
value = value or _now()
year, week, _ = value.isocalendar()
return f"{year}-W{week:02d}"
def _parse_date(value: str):
parsed = _parse(value)
return parsed.date() if parsed else None
@ -88,45 +82,3 @@ PERK_COLUMN = {perk.key: f"perk_{perk.key}" for perk in economy.PERKS}
def _update_farm(farm_uid: str, fields: dict) -> None:
fields = {**fields, "uid": farm_uid, "updated_at": _iso(_now())}
_farms().update(fields, ["uid"])
def conditional_update_farm(farm_uid: str, set_clause: str, where_clause: str, params: dict) -> int:
from sqlalchemy import text
from devplacepy.database import db
sql = (
f"UPDATE game_farms SET {set_clause}, updated_at = :updated_at "
f"WHERE uid = :farm_uid AND ({where_clause})"
)
bind = {**params, "updated_at": _iso(_now()), "farm_uid": farm_uid}
with db:
result = db.executable.execute(text(sql), bind)
return result.rowcount
def credit_farm(
farm: dict,
*,
coins: int = 0,
xp: int = 0,
harvests: int = 0,
era_active: bool = False,
extra: dict | None = None,
) -> dict:
new_xp = int(farm.get("xp", 0)) + xp
fields = {
"coins": max(0, int(farm.get("coins", 0)) + coins),
"xp": new_xp,
"level": economy.level_for_xp(new_xp),
"total_harvests": int(farm.get("total_harvests", 0)) + harvests,
"lifetime_coins_earned": max(0, _lvl(farm, "lifetime_coins_earned") + max(0, coins)),
"lifetime_harvests": _lvl(farm, "lifetime_harvests") + harvests,
}
if era_active:
fields["era_coins"] = _lvl(farm, "era_coins") + max(0, coins)
fields["era_harvests"] = _lvl(farm, "era_harvests") + harvests
if extra:
fields.update(extra)
_update_farm(farm["uid"], fields)
return {**farm, **fields}

View File

@ -1,62 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from sqlalchemy.exc import IntegrityError
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
from .. import economy
from .common import GameError, _iso, _now, _update_farm, conditional_update_farm
from .farm import ensure_farm
def _cosmetics():
return get_table("game_cosmetics")
def owned_cosmetic_keys(user_uid: str) -> set[str]:
return {row["cosmetic_key"] for row in _cosmetics().find(user_uid=user_uid)}
def buy_cosmetic(user: dict, key: str) -> dict:
cosmetic = economy.cosmetic_for(key)
if not cosmetic:
raise GameError("Unknown cosmetic.")
farm = ensure_farm(user["uid"])
now = _iso(_now())
row_uid = generate_uid()
try:
_cosmetics().insert(
{
"uid": row_uid,
"user_uid": user["uid"],
"cosmetic_key": key,
"purchased_at": now,
"created_at": now,
}
)
except IntegrityError:
raise GameError("You already own that cosmetic.")
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = coins - :cost",
where_clause="coins >= :cost",
params={"cost": cosmetic.cost_coins},
)
if rows == 0:
_cosmetics().delete(uid=row_uid)
raise GameError("Not enough coins for that cosmetic.")
return {"key": key, "spent": cosmetic.cost_coins}
def equip_title(user: dict, key: str) -> dict:
cosmetic = economy.cosmetic_for(key)
if not cosmetic or cosmetic.kind != "title":
raise GameError("Unknown title.")
if key not in owned_cosmetic_keys(user["uid"]):
raise GameError("You do not own that title.")
farm = ensure_farm(user["uid"])
_update_farm(farm["uid"], {"active_title": key})
return {"active_title": key}

View File

@ -1,86 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timedelta
from .. import economy
from .common import GameError, _iso, _lvl, _parse, conditional_update_farm
from .farm import ensure_farm, get_farm
def upgrade_defense(user: dict) -> dict:
farm = ensure_farm(user["uid"])
level = _lvl(farm, "defense_level")
next_tier = economy.next_defense_tier(level)
if not next_tier:
raise GameError("Defense is already at the top tier.")
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = coins - :cost, defense_level = :new_level",
where_clause="COALESCE(defense_level, 0) = :current_level AND coins >= :cost",
params={"cost": next_tier.upgrade_cost, "new_level": next_tier.level, "current_level": level},
)
if rows == 0:
farm = get_farm(user["uid"])
current_level = _lvl(farm, "defense_level")
if current_level != level:
raise GameError("Defense already changed - refresh and try again.")
raise GameError("Not enough coins to upgrade defense.")
return {"defense_level": next_tier.level, "spent": next_tier.upgrade_cost}
def charge_upkeep(farm: dict, now: datetime) -> dict:
level = _lvl(farm, "defense_level")
if level <= 0:
return farm
last_raw = farm.get("defense_last_upkeep_at") or ""
last = _parse(last_raw)
if not last:
rows = conditional_update_farm(
farm["uid"],
set_clause="defense_last_upkeep_at = :ts",
where_clause="(defense_last_upkeep_at IS NULL OR defense_last_upkeep_at = '') AND COALESCE(defense_level, 0) = :level",
params={"ts": _iso(now), "level": level},
)
if rows:
return {**farm, "defense_last_upkeep_at": _iso(now)}
return get_farm(farm["user_uid"]) or farm
elapsed_days = int((now - last).total_seconds() // 86400)
if elapsed_days < 1:
return farm
days_due = min(elapsed_days, economy.UPKEEP_GRACE_DAYS)
tier = economy.defense_tier(level)
coins = int(farm.get("coins", 0))
total_due = economy.daily_upkeep(tier, coins) * days_due
new_timestamp = _iso(last + timedelta(days=elapsed_days))
if coins >= total_due:
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = coins - :total_due, defense_last_upkeep_at = :new_ts",
where_clause="defense_last_upkeep_at = :expected_last AND COALESCE(defense_level, 0) = :expected_level AND coins >= :total_due",
params={
"total_due": total_due,
"new_ts": new_timestamp,
"expected_last": last_raw,
"expected_level": level,
},
)
if rows == 0:
return get_farm(farm["user_uid"]) or farm
return {**farm, "coins": coins - total_due, "defense_last_upkeep_at": new_timestamp}
new_level = max(0, level - 1)
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = 0, defense_level = :new_level, defense_last_upkeep_at = :new_ts",
where_clause="defense_last_upkeep_at = :expected_last AND COALESCE(defense_level, 0) = :expected_level",
params={"new_level": new_level, "new_ts": new_timestamp, "expected_last": last_raw, "expected_level": level},
)
if rows == 0:
return get_farm(farm["user_uid"]) or farm
return {
**farm,
"coins": 0,
"defense_level": new_level,
"defense_last_upkeep_at": new_timestamp,
}

View File

@ -1,148 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from devplacepy.database import get_table, get_users_by_uids
from devplacepy.utils import generate_uid
from .. import economy
from .common import GameError, _farms, _iso, _lvl, _now, _update_farm
def _eras():
return get_table("game_eras")
def _era_results():
return get_table("game_era_results")
def active_era() -> dict | None:
return _eras().find_one(active=1)
def active_era_name() -> str | None:
era = active_era()
return era["name"] if era else None
def start_era(name: str, duration_days: int) -> dict:
if active_era():
raise GameError("An Era is already running.")
now = _now()
ends_at = now.replace(microsecond=0)
from datetime import timedelta
ends_at = ends_at + timedelta(days=max(1, duration_days))
last = list(_eras().find(order_by=["-era_number"]))
era_number = (int(last[0]["era_number"]) + 1) if last else 1
row = {
"uid": generate_uid(),
"era_number": era_number,
"name": name,
"started_at": _iso(now),
"ends_at": _iso(ends_at),
"active": 1,
"created_at": _iso(now),
}
_eras().insert(row)
now_iso = _iso(now)
for farm in _farms().find():
_update_farm(
farm["uid"],
{"era_coins": 0, "era_harvests": 0, "era_joined_at": now_iso},
)
return row
def end_era() -> dict:
era = active_era()
if not era:
raise GameError("No Era is currently running.")
farms = [f for f in _farms().find() if _lvl(f, "era_coins") or _lvl(f, "era_harvests")]
ranked = sorted(
farms,
key=lambda f: economy.era_score(
_lvl(f, "era_coins"), _lvl(f, "era_harvests"), _lvl(f, "prestige")
),
reverse=True,
)
now = _iso(_now())
from .cosmetics import _cosmetics
era_cosmetics = [c for c in economy.COSMETICS if c.era_key == era["name"]]
for rank, farm in enumerate(ranked, start=1):
stars_award = economy.era_reward_stars(rank)
reward_cosmetic_key = ""
if stars_award and era_cosmetics:
reward_cosmetic_key = era_cosmetics[(rank - 1) % len(era_cosmetics)].key
if not _cosmetics().find_one(
user_uid=farm["user_uid"], cosmetic_key=reward_cosmetic_key
):
_cosmetics().insert(
{
"uid": generate_uid(),
"user_uid": farm["user_uid"],
"cosmetic_key": reward_cosmetic_key,
"purchased_at": now,
"created_at": now,
}
)
reset_fields = {"era_coins": 0, "era_harvests": 0}
if stars_award:
reset_fields["stars"] = _lvl(farm, "stars") + stars_award
_update_farm(farm["uid"], reset_fields)
_era_results().insert(
{
"uid": generate_uid(),
"era_number": era["era_number"],
"user_uid": farm["user_uid"],
"rank": rank,
"era_score": economy.era_score(
_lvl(farm, "era_coins"), _lvl(farm, "era_harvests"), _lvl(farm, "prestige")
),
"era_coins_final": _lvl(farm, "era_coins"),
"reward_stars": stars_award,
"reward_cosmetic_key": reward_cosmetic_key,
"created_at": now,
}
)
_eras().update({"uid": era["uid"], "active": 0}, ["uid"])
return {"era_number": era["era_number"], "participants": len(ranked)}
def leaderboard_era(limit: int = 25) -> list[dict]:
era = active_era()
if not era:
return []
farms = sorted(
_farms().find(),
key=lambda f: economy.era_score(
_lvl(f, "era_coins"), _lvl(f, "era_harvests"), _lvl(f, "prestige")
),
reverse=True,
)[:limit]
if not farms:
return []
users = get_users_by_uids([f["user_uid"] for f in farms])
entries = []
for rank, farm in enumerate(farms, start=1):
owner = users.get(farm["user_uid"])
if not owner:
continue
entries.append(
{
"rank": rank,
"username": owner.get("username", ""),
"level": int(farm.get("level", 1)),
"xp": int(farm.get("xp", 0)),
"coins": int(farm.get("coins", 0)),
"total_harvests": int(farm.get("total_harvests", 0)),
"prestige": int(farm.get("prestige") or 0),
"score": economy.era_score(
_lvl(farm, "era_coins"), _lvl(farm, "era_harvests"), _lvl(farm, "prestige")
),
"title": economy.cosmetic_title_name(farm.get("active_title") or ""),
}
)
return entries

View File

@ -6,7 +6,7 @@ from devplacepy.cache import TTLCache
from devplacepy.utils import generate_uid
from .. import economy
from .common import GameError, _farms, _iso, _lvl, _now, _plots, conditional_update_farm
from .common import GameError, _farms, _iso, _now, _plots, _update_farm
_leaderboard_cache = TTLCache(ttl=15, max_size=8)
@ -74,18 +74,11 @@ def buy_plot(user: dict) -> dict:
if plot_count >= economy.MAX_PLOTS:
raise GameError("All plots are already unlocked.")
cost = economy.plot_cost(plot_count)
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = coins - :cost, plot_count = :new_count",
where_clause="plot_count = :current_count AND coins >= :cost",
params={"cost": cost, "new_count": plot_count + 1, "current_count": plot_count},
)
if rows == 0:
farm = get_farm(user["uid"])
if int(farm.get("plot_count", economy.STARTING_PLOTS)) != plot_count:
raise GameError("Plot count already changed - refresh and try again.")
coins = int(farm.get("coins", 0))
if coins < cost:
raise GameError("Not enough coins for a new plot.")
_create_plot(farm["uid"], user["uid"], plot_count, _iso(_now()))
_update_farm(farm["uid"], {"coins": coins - cost, "plot_count": plot_count + 1})
return {"plot_count": plot_count + 1, "spent": cost}
@ -95,17 +88,13 @@ def upgrade_ci(user: dict) -> dict:
next_tier = economy.next_ci_tier(ci_tier)
if not next_tier:
raise GameError("CI is already at the top tier.")
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = coins - :cost, ci_tier = :new_tier",
where_clause="ci_tier = :current_tier AND coins >= :cost",
params={"cost": next_tier.upgrade_cost, "new_tier": next_tier.tier, "current_tier": ci_tier},
)
if rows == 0:
farm = get_farm(user["uid"])
if int(farm.get("ci_tier", 1)) != ci_tier:
raise GameError("CI tier already changed - refresh and try again.")
coins = int(farm.get("coins", 0))
if coins < next_tier.upgrade_cost:
raise GameError("Not enough coins to upgrade CI.")
_update_farm(
farm["uid"],
{"coins": coins - next_tier.upgrade_cost, "ci_tier": next_tier.tier},
)
return {"ci_tier": next_tier.tier, "spent": next_tier.upgrade_cost}
@ -138,151 +127,7 @@ def leaderboard(limit: int = 25) -> list[dict]:
"total_harvests": int(farm.get("total_harvests", 0)),
"prestige": int(farm.get("prestige") or 0),
"score": economy.farm_score(farm),
"title": economy.cosmetic_title_name(farm.get("active_title") or ""),
}
)
_leaderboard_cache.set(f"top:{limit}", entries)
return entries
def _entry(rank: int, farm: dict, owner: dict, score: int) -> dict:
return {
"rank": rank,
"username": owner.get("username", ""),
"level": int(farm.get("level", 1)),
"xp": int(farm.get("xp", 0)),
"coins": int(farm.get("coins", 0)),
"total_harvests": int(farm.get("total_harvests", 0)),
"prestige": int(farm.get("prestige") or 0),
"score": score,
"title": economy.cosmetic_title_name(farm.get("active_title") or ""),
}
def _ranked_entries(farms: list[dict], score_fn, limit: int) -> list[dict]:
ranked = sorted(farms, key=score_fn, reverse=True)[:limit]
if not ranked:
return []
from devplacepy.database import get_users_by_uids
users = get_users_by_uids([farm["user_uid"] for farm in ranked])
entries = []
for rank, farm in enumerate(ranked, start=1):
owner = users.get(farm["user_uid"])
if not owner:
continue
entries.append(_entry(rank, farm, owner, score_fn(farm)))
return entries
def leaderboard_prestige(limit: int = 25) -> list[dict]:
return _ranked_entries(
list(_farms().find()),
lambda f: _lvl(f, "prestige") * 1_000_000 + _lvl(f, "stars"),
limit,
)
def leaderboard_harvests_week(limit: int = 25) -> list[dict]:
return _ranked_entries(list(_farms().find()), lambda f: _lvl(f, "harvests_week"), limit)
def leaderboard_fair_play(limit: int = 25) -> list[dict]:
return _ranked_entries(
list(_farms().find()),
lambda f: economy.fair_play_score(_lvl(f, "harvests_week"), int(f.get("coins", 0))),
limit,
)
def leaderboard_time_to_kernel(limit: int = 25) -> list[dict]:
farms = [
f
for f in _farms().find()
if _lvl(f, "prestige") > 0
and _lvl(f, "last_kernel_harvest_prestige") == _lvl(f, "prestige")
]
ranked = sorted(farms, key=lambda f: _lvl(f, "time_to_kernel_seconds"))[:limit]
if not ranked:
return []
from devplacepy.database import get_users_by_uids
users = get_users_by_uids([farm["user_uid"] for farm in ranked])
entries = []
for rank, farm in enumerate(ranked, start=1):
owner = users.get(farm["user_uid"])
if not owner:
continue
entry = _entry(rank, farm, owner, _lvl(farm, "time_to_kernel_seconds"))
entry["time_to_kernel_seconds"] = _lvl(farm, "time_to_kernel_seconds")
entries.append(entry)
return entries
RAID_EFFICIENCY_WINDOW_DAYS = 30
def leaderboard_raid_efficiency(limit: int = 25) -> list[dict]:
from datetime import timedelta
from devplacepy.database import db, get_users_by_uids
cutoff = _iso(_now() - timedelta(days=RAID_EFFICIENCY_WINDOW_DAYS))
rows = db.query(
"SELECT thief_uid, COUNT(*) AS raids, SUM(coins) AS total_coins "
"FROM game_steals WHERE stolen_at >= :cutoff "
"GROUP BY thief_uid HAVING COUNT(*) >= :min_raids "
"ORDER BY (SUM(coins) * 1.0 / COUNT(*)) DESC LIMIT :limit",
cutoff=cutoff,
min_raids=economy.MIN_RAIDS_FOR_EFFICIENCY_BOARD,
limit=limit,
)
ranked = [
(row["thief_uid"], int(row["raids"]), int(row["total_coins"] or 0)) for row in rows
]
if not ranked:
return []
uids = [uid for uid, _, _ in ranked]
users = get_users_by_uids(uids)
farms_table = _farms()
farms_by_uid = {
f["user_uid"]: f for f in farms_table.find(farms_table.table.columns.user_uid.in_(uids))
}
entries = []
for rank, (uid, raids, total_coins) in enumerate(ranked, start=1):
owner = users.get(uid)
if not owner:
continue
farm = farms_by_uid.get(uid, {})
avg = total_coins / raids
entry = _entry(rank, farm, owner, round(avg))
entry["raid_avg"] = round(avg, 1)
entries.append(entry)
return entries
LEADERBOARD_BOARDS = {
"score": leaderboard,
"prestige": leaderboard_prestige,
"harvests": leaderboard_harvests_week,
"raids": leaderboard_raid_efficiency,
"time_to_kernel": leaderboard_time_to_kernel,
"fair_play": leaderboard_fair_play,
}
_board_cache = TTLCache(ttl=15, max_size=32)
def leaderboard_for(board: str, limit: int = 25) -> list[dict]:
cache_key = f"{board}:{limit}"
cached = _board_cache.get(cache_key)
if cached is not None:
return cached
if board == "era":
from .era import leaderboard_era
entries = leaderboard_era(limit)
else:
entries = LEADERBOARD_BOARDS.get(board, leaderboard)(limit)
_board_cache.set(cache_key, entries)
return entries

View File

@ -1,51 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import random
from .. import economy
from .common import GameError, _lvl, conditional_update_farm
from .farm import ensure_farm, get_farm
INFRA_COLUMN = {i.key: f"infra_{i.key}" for i in economy.INFRASTRUCTURE}
def buy_infrastructure(user: dict, key: str) -> dict:
infra = economy.infra_for(key)
if not infra:
raise GameError("Unknown infrastructure.")
farm = ensure_farm(user["uid"])
column = INFRA_COLUMN[infra.key]
rows = conditional_update_farm(
farm["uid"],
set_clause=f"coins = coins - :cost, {column} = 1",
where_clause=(
f"COALESCE({column}, 0) = 0 AND coins >= :cost "
f"AND COALESCE(prestige, 0) >= :min_prestige"
),
params={"cost": infra.cost, "min_prestige": infra.min_prestige},
)
if rows == 0:
farm = get_farm(user["uid"])
if _lvl(farm, column):
raise GameError("You already own that infrastructure.")
if _lvl(farm, "prestige") < infra.min_prestige:
raise GameError(f"{infra.name} requires prestige {infra.min_prestige}.")
raise GameError("Not enough coins for that infrastructure.")
return {"key": infra.key, "spent": infra.cost}
def owns_infrastructure(farm: dict, key: str) -> bool:
column = INFRA_COLUMN.get(key)
return bool(column and _lvl(farm, column))
def roll_canary(coins_gain: int, plant_cost: int) -> int:
roll = random.random()
if roll < economy.CANARY_DOUBLE_CHANCE:
return coins_gain * 2
if roll < economy.CANARY_DOUBLE_CHANCE + economy.CANARY_FAIL_CHANCE:
return min(coins_gain, plant_cost)
return coins_gain

View File

@ -1,77 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timedelta
from devplacepy.cache import TTLCache
from devplacepy.database import db, get_table
from devplacepy.utils import generate_uid
from .. import economy
from .common import _iso
_saturation_cache = TTLCache(ttl=30, max_size=32)
def _ticks():
return get_table("game_market_ticks")
def _hour_bucket(now: datetime) -> str:
return now.strftime("%Y-%m-%dT%H")
def record_harvest_tick(crop_key: str, now: datetime) -> None:
with db:
db.query(
"INSERT INTO game_market_ticks (uid, crop_key, hour_bucket, harvests, updated_at) "
"VALUES (:uid, :crop_key, :hour_bucket, 1, :now) "
"ON CONFLICT(crop_key, hour_bucket) DO UPDATE SET "
"harvests = harvests + 1, updated_at = :now",
uid=generate_uid(),
crop_key=crop_key,
hour_bucket=_hour_bucket(now),
now=_iso(now),
)
_saturation_cache.clear()
def recent_harvests(crop_key: str, window_hours: int) -> int:
cache_key = f"{crop_key}:{window_hours}"
cached = _saturation_cache.get(cache_key)
if cached is not None:
return cached
from .common import _now
cutoff = _hour_bucket(_now() - timedelta(hours=window_hours))
row = db.query(
"SELECT COALESCE(SUM(harvests), 0) AS total FROM game_market_ticks "
"WHERE crop_key = :crop_key AND hour_bucket >= :cutoff",
crop_key=crop_key,
cutoff=cutoff,
)
total = 0
for result in row:
total = int(result["total"] or 0)
break
_saturation_cache.set(cache_key, total)
return total
def market_factor_for(crop_key: str) -> float:
recent = recent_harvests(crop_key, economy.MARKET_WINDOW_HOURS)
saturation = economy.market_saturation_factor(recent)
return saturation * economy.market_buff_factor(crop_key, saturation)
def prune_ticks(older_than_hours: int = 96) -> int:
from .common import _now
cutoff = _hour_bucket(_now() - timedelta(hours=older_than_hours))
table = _ticks()
stale = [row["uid"] for row in table.find() if row.get("hour_bucket", "") < cutoff]
for uid in stale:
table.delete(uid=uid)
return len(stale)

View File

@ -1,34 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .. import economy
from .common import GameError, _lvl, conditional_update_farm
from .farm import ensure_farm, get_farm
MASTERY_COLUMN = {m.key: f"mastery_{m.key}" for m in economy.MASTERY_UPGRADES}
def upgrade_mastery(user: dict, key: str) -> dict:
upgrade = economy.mastery_for(key)
if not upgrade:
raise GameError("Unknown Mastery upgrade.")
farm = ensure_farm(user["uid"])
column = MASTERY_COLUMN[upgrade.key]
level = _lvl(farm, column)
if level >= upgrade.max_level:
raise GameError("That Mastery upgrade is maxed out.")
cost = economy.mastery_cost(upgrade, level)
rows = conditional_update_farm(
farm["uid"],
set_clause=f"mastery_points = COALESCE(mastery_points, 0) - :cost, {column} = :new_level",
where_clause=f"COALESCE({column}, 0) = :current_level AND COALESCE(mastery_points, 0) >= :cost",
params={"cost": cost, "new_level": level + 1, "current_level": level},
)
if rows == 0:
farm = get_farm(user["uid"])
if _lvl(farm, column) != level:
raise GameError("That Mastery upgrade already changed - refresh and try again.")
raise GameError("Not enough Mastery points for that upgrade.")
return {"key": upgrade.key, "level": level + 1, "spent": cost}

View File

@ -5,15 +5,12 @@ from __future__ import annotations
from devplacepy.utils import generate_uid
from .. import economy
from .common import _iso, _iso_week, _lvl, _now, _quests, _today
from .common import _iso, _now, _quests, _today
from .farm import get_farm
WEEKLY_SLOT_INDEX = 3
def ensure_quests(farm: dict, day: str) -> None:
if _quests().find_one(farm_uid=farm["uid"], day=day, scope="daily"):
if _quests().find_one(farm_uid=farm["uid"], day=day):
return
now = _iso(_now())
for index, quest in enumerate(economy.daily_quests(farm["user_uid"], day)):
@ -23,7 +20,6 @@ def ensure_quests(farm: dict, day: str) -> None:
"farm_uid": farm["uid"],
"user_uid": farm["user_uid"],
"day": day,
"scope": "daily",
"slot_index": index,
"kind": quest["kind"],
"label": quest["label"],
@ -31,7 +27,6 @@ def ensure_quests(farm: dict, day: str) -> None:
"progress": 0,
"reward_coins": quest["reward_coins"],
"reward_xp": quest["reward_xp"],
"reward_stars": 0,
"claimed": 0,
"created_at": now,
"updated_at": now,
@ -39,35 +34,6 @@ def ensure_quests(farm: dict, day: str) -> None:
)
def ensure_weekly_contract(farm: dict, iso_week: str) -> None:
if not _lvl(farm, "mastery_contracts"):
return
if _quests().find_one(farm_uid=farm["uid"], day=iso_week, scope="weekly"):
return
now = _iso(_now())
contract = economy.weekly_contract(farm["user_uid"], iso_week)
_quests().insert(
{
"uid": generate_uid(),
"farm_uid": farm["uid"],
"user_uid": farm["user_uid"],
"day": iso_week,
"scope": "weekly",
"slot_index": WEEKLY_SLOT_INDEX,
"kind": contract["kind"],
"label": contract["label"],
"goal": contract["goal"],
"progress": 0,
"reward_coins": 0,
"reward_xp": contract["reward_xp"],
"reward_stars": contract["reward_stars"],
"claimed": 0,
"created_at": now,
"updated_at": now,
}
)
def advance_quests(user_uid: str, kind: str, amount: int) -> None:
if amount <= 0:
return
@ -75,22 +41,15 @@ def advance_quests(user_uid: str, kind: str, amount: int) -> None:
farm = get_farm(user_uid)
if not farm:
return
now = _now()
day = _today()
ensure_quests(farm, day)
iso_week = _iso_week(now)
ensure_weekly_contract(farm, iso_week)
rows = list(_quests().find(farm_uid=farm["uid"], day=day, kind=kind, scope="daily"))
rows += list(
_quests().find(farm_uid=farm["uid"], day=iso_week, kind=kind, scope="weekly")
)
for row in rows:
for row in _quests().find(farm_uid=farm["uid"], day=day, kind=kind):
if row.get("claimed"):
continue
goal = int(row.get("goal") or 0)
progress = min(goal, int(row.get("progress") or 0) + amount)
_quests().update(
{"uid": row["uid"], "progress": progress, "updated_at": _iso(now)},
{"uid": row["uid"], "progress": progress, "updated_at": _iso(_now())},
["uid"],
)
except Exception:

View File

@ -8,7 +8,6 @@ from datetime import datetime, timedelta
from .. import economy
from .common import (
PERK_COLUMN,
_iso_week,
_lvl,
_now,
_parse,
@ -16,12 +15,7 @@ from .common import (
_quests,
steal_cooldown_remaining,
)
from .cosmetics import owned_cosmetic_keys
from .era import active_era_name
from .farm import get_farm, get_plots
from .infrastructure import INFRA_COLUMN, owns_infrastructure
from .market import market_factor_for
from .mastery import MASTERY_COLUMN
from .quests import ensure_quests
@ -75,27 +69,22 @@ def serialize_plot(
)
grace = economy.effective_steal_grace(defense_level)
protected = bool(ready_at) and now < ready_at + timedelta(seconds=grace)
immune = bool(crop and crop.steal_immune)
eligible = (
state == "ready"
and not is_owner
and bool(viewer_uid)
and bool(crop)
and ready_at is not None
and not immune
)
can_steal = eligible and not protected and steal_locked_until <= 0
steal_reason = ""
if immune and state == "ready" and not is_owner:
steal_reason = "immune"
elif eligible and protected:
if eligible and protected:
steal_reason = "protected"
elif eligible and steal_locked_until > 0:
steal_reason = "cooldown"
market_factor = market_factor_for(crop.key) if crop else 1.0
steal_coins = (
economy.steal_reward_coins(
crop, yield_level, prestige, legacy_mult_level, defense_level, market_factor
crop, yield_level, prestige, legacy_mult_level, defense_level
)
if can_steal
else 0
@ -107,7 +96,7 @@ def serialize_plot(
)
reduce_by = int(remaining * economy.FERTILIZE_FRACTION)
eff_reward = economy.effective_reward_coins(
crop, yield_level, prestige, legacy_mult_level, market_factor
crop, yield_level, prestige, legacy_mult_level
)
fertilize_cost = economy.fertilize_click_cost(eff_reward, reduce_by, full_grow)
return {
@ -136,8 +125,6 @@ def serialize_farm(
farm: dict, *, viewer: dict | None, owner: dict, now: datetime | None = None
) -> dict:
from .actions import _auto_harvest
from .defense import charge_upkeep
from .treasury import grant_status, treasury_balance
now = now or _now()
viewer_uid = viewer["uid"] if viewer else ""
@ -145,9 +132,6 @@ def serialize_farm(
is_owner = viewer_uid == owner_uid
if is_owner and _lvl(farm, "legacy_autoharvest") > 0:
farm = _auto_harvest(farm, owner_uid, now)
if is_owner:
farm = charge_upkeep(farm, now)
farm = _reset_harvests_week(farm, now)
prestige = _lvl(farm, "prestige")
growth_level = _lvl(farm, "perk_growth")
discount_level = _lvl(farm, "perk_discount")
@ -185,23 +169,6 @@ def serialize_farm(
ci_entry = economy.CI_BY_TIER.get(ci_tier)
streak = _lvl(farm, "streak")
daily_available = is_owner and _daily_available(farm, now)
mastery_earned = _lvl(farm, "mastery_points_earned_total")
era_name = active_era_name()
registry_boost = owns_infrastructure(farm, "registry")
underdog_until = _parse(farm.get("underdog_boost_until") or "")
underdog_seconds = (
max(0, int((underdog_until - now).total_seconds())) if underdog_until else 0
)
building_defense_level = _lvl(farm, "defense_level")
defense_tier_info = economy.defense_tier(building_defense_level)
coins = int(farm.get("coins", 0))
refactor_fee = economy.refactor_cost(prestige, coins)
carryover_level = _lvl(farm, "legacy_carryover")
grant = (
grant_status(farm, now)
if is_owner
else {"available": False, "amount": 0, "reason": ""}
)
return {
"owner_username": owner.get("username", ""),
"owner_uid": owner_uid,
@ -238,10 +205,6 @@ def serialize_farm(
prestige,
legacy_mult_level,
legacy_speed_level,
market_factor_for(crop.key),
mastery_earned,
era_name,
registry_boost,
)
for crop in economy.CROPS
],
@ -249,20 +212,6 @@ def serialize_farm(
"prestige_multiplier": economy.prestige_multiplier(prestige),
"prestige_min_level": economy.PRESTIGE_MIN_LEVEL,
"prestige_available": is_owner and level >= economy.PRESTIGE_MIN_LEVEL,
"refactor_cost": refactor_fee,
"refactor_affordable": is_owner
and level >= economy.PRESTIGE_MIN_LEVEL
and coins >= refactor_fee,
"refactor_carryover_pct": round(
economy.refactor_carryover_fraction(carryover_level) * 100
),
"refactor_carryover_preview": economy.refactor_carryover(
coins, refactor_fee, carryover_level
),
"grant_available": grant["available"],
"grant_amount": grant["amount"],
"grant_reason": grant["reason"],
"treasury_balance": treasury_balance() if is_owner else 0,
"streak": streak,
"daily_available": daily_available,
"daily_reward": economy.daily_reward(streak + 1 if daily_available else streak),
@ -271,99 +220,9 @@ def serialize_farm(
"stars": _lvl(farm, "stars"),
"legacy": _serialize_legacy(farm) if is_owner else [],
"steal_cooldown_seconds": steal_locked_until,
"mastery_points": _lvl(farm, "mastery_points"),
"mastery_points_earned_total": mastery_earned,
"mastery": _serialize_mastery(farm) if is_owner else [],
"infrastructure": _serialize_infrastructure(farm) if is_owner else [],
"defense_level": building_defense_level,
"defense_tier_name": defense_tier_info.name,
"defense_upkeep_daily": economy.daily_upkeep(defense_tier_info, int(farm.get("coins", 0))),
"defense_next_cost": (
economy.next_defense_tier(building_defense_level).upgrade_cost
if economy.next_defense_tier(building_defense_level)
else 0
),
"cosmetics": _serialize_cosmetics(owner_uid) if is_owner else [],
"active_title": farm.get("active_title") or "",
"underdog_boost_seconds_remaining": underdog_seconds,
"mastery_analytics_unlocked": bool(_lvl(farm, "mastery_analytics")),
"lifetime_coins_earned": _lvl(farm, "lifetime_coins_earned"),
"lifetime_harvests": _lvl(farm, "lifetime_harvests"),
"harvests_week": _lvl(farm, "harvests_week"),
"era_active": era_name is not None,
"era_name": era_name or "",
"era_coins": _lvl(farm, "era_coins"),
"era_harvests": _lvl(farm, "era_harvests"),
}
def _reset_harvests_week(farm: dict, now: datetime) -> dict:
from .common import _update_farm
current_week = _iso_week(now)
if farm.get("harvests_week_start") == current_week:
return farm
fields = {"harvests_week": 0, "harvests_week_start": current_week}
_update_farm(farm["uid"], fields)
return {**farm, **fields}
def _serialize_mastery(farm: dict) -> list[dict]:
upgrades = []
for m in economy.MASTERY_UPGRADES:
level = _lvl(farm, MASTERY_COLUMN[m.key])
maxed = level >= m.max_level
upgrades.append(
{
"key": m.key,
"name": m.name,
"icon": m.icon,
"description": m.description,
"level": level,
"max_level": m.max_level,
"cost": 0 if maxed else economy.mastery_cost(m, level),
"maxed": maxed,
"effect": m.description,
}
)
return upgrades
def _serialize_infrastructure(farm: dict) -> list[dict]:
rows = []
for infra in economy.INFRASTRUCTURE:
owned = bool(_lvl(farm, INFRA_COLUMN[infra.key]))
rows.append(
{
"key": infra.key,
"name": infra.name,
"icon": infra.icon,
"description": infra.description,
"cost": infra.cost,
"min_prestige": infra.min_prestige,
"owned": owned,
}
)
return rows
def _serialize_cosmetics(user_uid: str) -> list[dict]:
owned = owned_cosmetic_keys(user_uid)
return [
{
"key": c.key,
"name": c.name,
"icon": c.icon,
"description": c.description,
"cost_coins": c.cost_coins,
"kind": c.kind,
"owned": c.key in owned,
}
for c in economy.COSMETICS
if c.era_key is None
]
def _daily_available(farm: dict, now: datetime) -> bool:
last = _parse_date(farm.get("last_daily_at") or "")
return last != now.date()
@ -412,24 +271,15 @@ def _serialize_legacy(farm: dict) -> list[dict]:
def _serialize_quests(user_uid: str, now: datetime) -> list[dict]:
from .quests import ensure_weekly_contract
farm = get_farm(user_uid)
if not farm:
return []
day = now.date().isoformat()
ensure_quests(farm, day)
rows = sorted(
_quests().find(farm_uid=farm["uid"], day=day, scope="daily"),
_quests().find(farm_uid=farm["uid"], day=day),
key=lambda row: row.get("slot_index", 0),
)
if _lvl(farm, "mastery_contracts"):
iso_week = _iso_week(now)
ensure_weekly_contract(farm, iso_week)
rows += sorted(
_quests().find(farm_uid=farm["uid"], day=iso_week, scope="weekly"),
key=lambda row: row.get("slot_index", 0),
)
quests = []
for row in rows:
goal = int(row.get("goal") or 0)
@ -445,8 +295,6 @@ def _serialize_quests(user_uid: str, now: datetime) -> list[dict]:
"reward_xp": int(row.get("reward_xp") or 0),
"claimed": claimed,
"can_claim": (not claimed) and goal > 0 and progress >= goal,
"scope": row.get("scope", "daily"),
"reward_stars": int(row.get("reward_stars") or 0),
}
)
return quests

View File

@ -1,158 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime
from devplacepy.database import get_table
from .. import economy
from .common import GameError, _iso, _iso_week, _lvl, _now, conditional_update_farm
from .farm import ensure_farm, get_farm
TREASURY_UID = "treasury-main"
def _treasury():
return get_table("game_treasury")
def ensure_treasury() -> dict:
row = _treasury().find_one(uid=TREASURY_UID)
if row:
return row
_treasury().insert(
{
"uid": TREASURY_UID,
"balance": 0,
"collected_total": 0,
"granted_total": 0,
"updated_at": _iso(_now()),
}
)
return _treasury().find_one(uid=TREASURY_UID)
def treasury_balance() -> int:
row = _treasury().find_one(uid=TREASURY_UID)
return int(row.get("balance") or 0) if row else 0
def _treasury_update(set_clause: str, where_clause: str, params: dict) -> int:
from sqlalchemy import text
from devplacepy.database import db
sql = (
f"UPDATE game_treasury SET {set_clause}, updated_at = :updated_at "
f"WHERE uid = :treasury_uid AND ({where_clause})"
)
bind = {**params, "updated_at": _iso(_now()), "treasury_uid": TREASURY_UID}
with db:
result = db.executable.execute(text(sql), bind)
return result.rowcount
def credit_treasury(amount: int) -> None:
if amount <= 0:
return
ensure_treasury()
_treasury_update(
set_clause=(
"balance = COALESCE(balance, 0) + :amount, "
"collected_total = COALESCE(collected_total, 0) + :amount"
),
where_clause="1 = 1",
params={"amount": amount},
)
def _debit_treasury(amount: int) -> bool:
ensure_treasury()
rows = _treasury_update(
set_clause=(
"balance = COALESCE(balance, 0) - :amount, "
"granted_total = COALESCE(granted_total, 0) + :amount"
),
where_clause="COALESCE(balance, 0) >= :amount",
params={"amount": amount},
)
return rows > 0
def _refund_treasury(amount: int) -> None:
if amount <= 0:
return
_treasury_update(
set_clause=(
"balance = COALESCE(balance, 0) + :amount, "
"granted_total = COALESCE(granted_total, 0) - :amount"
),
where_clause="1 = 1",
params={"amount": amount},
)
def grant_status(farm: dict, now: datetime) -> dict:
week = _iso_week(now)
if farm.get("last_grant_week") == week:
return {"available": False, "amount": 0, "reason": "Grant already claimed this week."}
if _lvl(farm, "prestige") > economy.GRANT_MAX_PRESTIGE:
return {
"available": False,
"amount": 0,
"reason": f"Grants support farms up to prestige {economy.GRANT_MAX_PRESTIGE}.",
}
if int(farm.get("coins", 0)) >= economy.GRANT_WEALTH_CEILING:
return {
"available": False,
"amount": 0,
"reason": f"Grants support farms below {economy.GRANT_WEALTH_CEILING} coins.",
}
if _lvl(farm, "harvests_week") < economy.GRANT_MIN_WEEK_HARVESTS:
return {
"available": False,
"amount": 0,
"reason": (
f"Harvest at least {economy.GRANT_MIN_WEEK_HARVESTS} builds this week to qualify."
),
}
amount = economy.grant_amount(treasury_balance())
if amount <= 0:
return {
"available": False,
"amount": 0,
"reason": "The treasury is empty - refactor fees fill it.",
}
return {"available": True, "amount": amount, "reason": ""}
def claim_grant(user: dict) -> dict:
farm = ensure_farm(user["uid"])
now = _now()
status = grant_status(farm, now)
if not status["available"]:
raise GameError(status["reason"])
amount = status["amount"]
week = _iso_week(now)
if not _debit_treasury(amount):
raise GameError("The treasury is empty - refactor fees fill it.")
rows = conditional_update_farm(
farm["uid"],
set_clause=(
"coins = coins + :amount, "
"last_grant_week = :week, "
"lifetime_coins_earned = COALESCE(lifetime_coins_earned, 0) + :amount"
),
where_clause=(
"(last_grant_week IS NULL OR last_grant_week != :week) AND coins < :ceiling"
),
params={"amount": amount, "week": week, "ceiling": economy.GRANT_WEALTH_CEILING},
)
if rows == 0:
_refund_treasury(amount)
farm = get_farm(user["uid"])
if farm and farm.get("last_grant_week") == week:
raise GameError("Grant already claimed this week.")
raise GameError("Your farm changed - refresh and try again.")
return {"amount": amount, "week": week}

View File

@ -4,69 +4,46 @@ This file documents the real-time direct-message chat subsystem. Claude Code aut
## Overview
The `/messages` feature is a live WebSocket chat layered over the SAME `messages` table and audit/notification cores as before; there is no parallel chat store. The WS only adds live delivery, typing, and read receipts on top of the existing persistence - presence is NOT part of this WS (see "Presence is not part of the messaging WS" below).
The `/messages` feature is a live WebSocket chat layered over the SAME `messages` table and audit/notification cores as before; there is no parallel chat store. The WS only adds live delivery, presence, typing, and read receipts on top of the existing persistence.
`messages.py` (the router, mounted at `/messages`) exposes: `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `GET /conversations` (flat JSON conversation list, for a client-side refresh without a full page reload), `POST /send` (no-JS fallback, now also accepting an attachment-only empty-content message), `POST /ws-ticket` (issues a short-lived WS auth ticket), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests).
`messages.py` (the router, mounted at `/messages`) exposes: `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `POST /send` (no-JS fallback), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests).
## Bounded page queries (load-bearing performance rule)
`get_conversations` is ONE window-function query (`ROW_NUMBER() OVER (PARTITION BY other_uid ORDER BY created_at DESC, id DESC)` over `sender_uid = :me OR receiver_uid = :me`, the `get_recent_comments_by_target_uids` precedent) that returns only each partner's latest row - never load the user's full message history into Python to build the sidebar. It backs both the server-rendered `GET /messages` page and the JSON `GET /messages/conversations` route (same helper, no duplicated query). `get_conversation_messages` loads at most `CONVERSATION_MESSAGE_LIMIT` (500) most-recent rows (`ORDER BY created_at DESC, id DESC LIMIT`, reversed to ascending in Python); older history stays in the DB and is simply not rendered. Both are covered by `idx_messages_conversation`/`_rev` (verified `EXPLAIN QUERY PLAN`, MULTI-INDEX OR, no table scan). `messages` is NOT in `SOFT_DELETE_TABLES`, so neither query filters `deleted_at`. Any new message listing must stay bounded and indexed the same way.
## `GET /messages/conversations`
Additive JSON endpoint (`require_user`), added so a frontend component can refresh the conversation list without a full page reload. It calls the exact same `get_conversations(user_uid)` helper `GET /messages` already uses, then projects each entry through `schemas.ConversationOut` (which nests `other_user` as `UserOut`, stripping `email`/`api_key`/`password_hash`) before returning `{"conversations": [...]}`. Like `GET /messages/search`, this is flat JSON, not content-negotiated through `respond()`.
`get_conversations` is ONE window-function query (`ROW_NUMBER() OVER (PARTITION BY other_uid ORDER BY created_at DESC, id DESC)` over `sender_uid = :me OR receiver_uid = :me`, the `get_recent_comments_by_target_uids` precedent) that returns only each partner's latest row - never load the user's full message history into Python to build the sidebar. `get_conversation_messages` loads at most `CONVERSATION_MESSAGE_LIMIT` (500) most-recent rows (`ORDER BY created_at DESC, id DESC LIMIT`, reversed to ascending in Python); older history stays in the DB and is simply not rendered. Both are covered by `idx_messages_conversation`/`_rev` (verified `EXPLAIN QUERY PLAN`, MULTI-INDEX OR, no table scan). `messages` is NOT in `SOFT_DELETE_TABLES`, so neither query filters `deleted_at`. Any new message listing must stay bounded and indexed the same way.
## WS endpoint `/messages/ws`
Lives on the existing messages router (no new router/prefix; already mounted at `/messages`). It is **NOT lock-owner gated** (deliberately - unlike `/devii/ws`): `await websocket.accept()`, resolve the owner via `_resolve_ws_user`, and accept on EVERY worker. Gating on `service_manager.owns_lock()` was the original design and was wrong here: when no worker holds the service lock (services disabled, or the lock not yet acquired) every socket got closed `4013` and the client fast-retried every 200ms forever (a connection storm with no stream, and the send form fell back to a full-page POST reload). Chat must not depend on the background-service lock. Messaging requires an authenticated user - guests are closed with `1008`. The receive loop is wrapped in `try/except WebSocketDisconnect` + a broad `except` (never crash the worker) and ALWAYS unregisters the socket in `finally`.
### `_resolve_ws_user` auth resolution order
1. **Session cookie** (`_user_from_session`) - the same-origin `page` mode path; browsers send cookies on a same-origin WS upgrade, so this needs no special handling.
2. **WS ticket** (`ticket` query param, tried AFTER the session-cookie check and BEFORE the header checks - additive, inserted here specifically so it never changes same-origin `page`-mode behavior): if present, `redeem_ticket(ticket)` looks it up; on success it resolves to the ticket's owner user row. This is the ONLY way a real browser can authenticate a cross-context (`embed` mode / third-party) WebSocket, because the native `WebSocket` constructor cannot set custom request headers - there is no way to attach `X-API-KEY`/`Authorization` to `new WebSocket(url)` in any browser. See "WS auth tickets" below for the full mechanism. If the ticket is missing/expired/already-used, resolution simply falls through to the header checks below (never a hard reject at this point - only the final `if not user` in the WS handler closes the socket).
3. **`X-API-KEY` header**, else **`Authorization: Bearer <key>`** - the existing non-browser API-client path (unreachable from a browser's native `WebSocket`, but real and unchanged for programmatic clients that can set headers, e.g. a server-side bot).
## WS auth tickets (`services/messaging/tickets.py`, table `ws_tickets`)
Short-lived, single-use tokens that let a browser-based WebSocket authenticate without ever needing to set a request header on the handshake. `POST /messages/ws-ticket` (`require_user`, so it accepts the same HTTP-reachable auth as any other guarded route - session cookie, `X-API-KEY`, or `Authorization: Bearer` - over a normal fetch/XHR, where headers work fine) calls `issue_ticket(user_uid)` and returns `{"ticket": "<opaque hex token>", "expires_in": 30}`. The client then opens `wss://.../messages/ws?ticket=<ticket>` - a query parameter, which a WS handshake CAN carry.
- `issue_ticket(user_uid) -> str` inserts a `ws_tickets` row (`uid` uuid7, `token` = `secrets.token_hex(24)`, `user_uid`, `created_at`, `expires_at` = now + 30s, `used_at=None`) and returns the token.
- `redeem_ticket(token) -> Optional[str]` looks the row up by `token`; returns `None` if missing, already used (`used_at` set), or past `expires_at`; otherwise stamps `used_at` and returns the `user_uid`. Redemption is single-use by construction - a ticket leaked via referrer or browser history cannot be replayed once redeemed, and it self-expires after 30 seconds even if never used.
- `ws_tickets` is NOT in `SOFT_DELETE_TABLES` - it is an ephemeral, single-use artifact, garbage-collected hard via `devplace messaging prune-tickets` (deletes rows where `expires_at < now`), the same shape as the zip/fork/backup `prune` CLI commands. `database/schema.py` `init_db()` ensures its columns (`uid`, `token`, `user_uid`, `created_at`, `expires_at`, `used_at`) and a unique index on `token` (the hot lookup path) plus an index on `expires_at` (the prune sweep).
- This is strictly additive to `_resolve_ws_user` - the existing session-cookie and header paths are completely unchanged, so `mode="page"` behavior and every existing non-browser API consumer are unaffected.
Lives on the existing messages router (no new router/prefix; already mounted at `/messages`). It is **NOT lock-owner gated** (deliberately - unlike `/devii/ws`): `await websocket.accept()`, resolve the owner via session cookie / api-key (`_resolve_ws_user` reuses `utils._user_from_session`/`_user_from_api_key`), and accept on EVERY worker. Gating on `service_manager.owns_lock()` was the original design and was wrong here: when no worker holds the service lock (services disabled, or the lock not yet acquired) every socket got closed `4013` and the client fast-retried every 200ms forever (a connection storm with no stream, and the send form fell back to a full-page POST reload). Chat must not depend on the background-service lock. Messaging requires an authenticated user - guests are closed with `1008`. The receive loop is wrapped in `try/except WebSocketDisconnect` + a broad `except` (never crash the worker) and ALWAYS unregisters the socket in `finally`.
## Connection registry
The `ConnectionManager` singleton `message_hub` (`services/messaging/hub.py`), **one per worker**: `user_uid -> set[WebSocket]`, plus a bounded (4000-entry) `delivered` LRU dedupe set (`mark_delivered`/`was_delivered`) used to dedupe direct vs relayed delivery. `register`/`unregister` track connections for delivery only - **no presence data lives here** (see below).
The `ConnectionManager` singleton `message_hub` (`services/messaging/hub.py`), **one per worker**: `user_uid -> set[WebSocket]`, plus an in-process `last_seen` map and a bounded `delivered` LRU set (`mark_delivered`/`was_delivered`, cap 4000) used to dedupe direct vs relayed delivery. `register`/`unregister` return whether the user just came online / went offline (for presence announces). `send_to_user`/`send_to_users` fan a frame out to all of a user's sockets (multi-tab) and swallow dead-socket errors. Presence and last-seen are in-process per worker (no DB column).
## Cross-worker delivery (the relay)
Because the WS accepts on every worker, a message persisted on worker A must still reach a recipient whose socket lives on worker B. `services/messaging/relay.py` `message_relay` (a per-worker singleton asyncio loop, started on the first socket connect, self-stops when `message_hub.has_connections()` is false) polls `SELECT * FROM messages WHERE id > :watermark` (uuid7-backed autoincrement `id`) every ~1s and pushes any row whose sender/receiver has a LOCAL socket and that was not already `was_delivered` to those local sockets, advancing the watermark to the max row seen. Same-worker sends are delivered INSTANTLY by `broadcast_message` (which calls `message_hub.mark_delivered` so the relay skips them); the relay only fills the cross-worker gap, so same-worker latency is zero and cross-worker latency is bounded by the poll interval. The relay primes its watermark to the current `MAX(id)` on first start so it never replays history. **Trade-off:** typing/read frames are in-process per worker only - across the two `make prod` workers those ephemeral signals reach only same-worker peers (message delivery is always correct). On a single dev worker everything is instant and complete.
Because the WS accepts on every worker, a message persisted on worker A must still reach a recipient whose socket lives on worker B. `services/messaging/relay.py` `message_relay` (a per-worker singleton asyncio loop, started on the first socket connect, self-stops when `message_hub.has_connections()` is false) polls `SELECT * FROM messages WHERE id > :watermark` (uuid7-backed autoincrement `id`) every ~1s and pushes any row whose sender/receiver has a LOCAL socket and that was not already `was_delivered` to those local sockets, advancing the watermark to the max row seen. Same-worker sends are delivered INSTANTLY by `broadcast_message` (which calls `message_hub.mark_delivered` so the relay skips them); the relay only fills the cross-worker gap, so same-worker latency is zero and cross-worker latency is bounded by the poll interval. The relay primes its watermark to the current `MAX(id)` on first start so it never replays history. **Trade-off:** typing/read/presence frames are in-process per worker only - across the two `make prod` workers those ephemeral signals reach only same-worker peers (message delivery is always correct). On a single dev worker everything is instant and complete.
## DRY persist choke point
`services/messaging/persist.py` `persist_message(sender, receiver_uid, content, attachment_uids, *, request=None, origin)` is the ONE function that inserts the row, links attachments, fires `create_notification` + `clear_messages_cache` + `create_mention_notifications`, logs, and writes the `message.send` audit event. Both the WS `send` handler and the HTTP `POST /messages/send` handler call it, so audit/notification/mention behavior is byte-identical on both paths (the Devii `send_message` action is `handler="http"` -> `POST /messages/send`, so it also flows through here and broadcasts live). Content is capped at 2000 chars server-side; `content` itself is allowed to be empty (`MessageForm.content` is `min_length=0`) as long as at least one attachment is present - `persist_message` is the single source of truth for that rule (`if not content and not attachment_uids: return None`), so the HTTP form model deliberately does not duplicate it. Whenever `request` is not `None` it audits via `audit.record(request, ...)` - this covers BOTH the HTTP `Request` and the WS path, since the WS handler passes `request=websocket` and a `WebSocket` object is just as non-`None` as a `Request` (`audit.record` never reads HTTP-specific attributes off it beyond what's already supplied explicitly via `user=sender`). `audit.record_system(..., actor_kind="user", origin=origin)` is the fallback used ONLY when `persist_message` is called with no request/websocket context at all (e.g. a future internal/system-originated send) - same `message.send` key/category either way, no new event invented; typing/read are ephemeral and NOT audited.
`services/messaging/persist.py` `persist_message(sender, receiver_uid, content, attachment_uids, *, request=None, origin)` is the ONE function that inserts the row, links attachments, fires `create_notification` + `clear_messages_cache` + `create_mention_notifications`, logs, and writes the `message.send` audit event. Both the WS `send` handler and the HTTP `POST /messages/send` handler call it, so audit/notification/mention behavior is byte-identical on both paths (the Devii `send_message` action is `handler="http"` -> `POST /messages/send`, so it also flows through here and broadcasts live). Content is capped at 2000 chars server-side. When `request` is a `Request` it audits via `audit.record`; on the WS path it now passes `request=websocket` (auditing via `audit.record_system` with `actor_kind="user"` + `origin="websocket"`, same `message.send` key/category - no new event invented; presence/typing/read are ephemeral and NOT audited).
## AI correction and AI modifier apply to direct messages, with LIVE delivery of the final content
`"messages": ("content",)` is in the `CORRECTABLE_FIELDS` registry and `persist_message` invokes `schedule_correction`/`schedule_modification` (sender = the user), so typing `@ai <instruction>` in a DM runs the AI modifier (default on + sync) and an enabled correction rewrites the content. Both send paths funnel through `routers/messages._finalize_and_broadcast(sender, message, request, client_id=None)`: it reads any pending SYNC correction/modification futures stashed on `request.scope[PENDING_SCOPE_KEY]` (the same `PENDING_SCOPE_KEY`/`loop.run_in_executor` mechanism the HTTP `await_pending_corrections` middleware uses) into `ai_processed = bool(pending)` **before** awaiting/clearing them, awaits them, RE-READS the message row, and broadcasts the FINAL (corrected/modified) content via `broadcast_message(sender, message, client_id, ai_processed=ai_processed)`. The HTTP `POST /send` and the WS `/ws` send path both call it; the WS path passes `request=websocket` to `persist_message` so the modifier stashes its executor future on the websocket scope and the handler awaits it directly (HTTP middleware does not run for websockets). A message with no `@ai` directive and correction off has nothing pending, so `ai_processed` is `False` and the broadcast is immediate with zero added latency. `broadcast_message` forwards `ai_processed` straight into `message_frame(..., ai_processed=ai_processed)` - see "WS protocol frames" below for the frame shape. This flag is informational only: the server never retains the pre-AI text anywhere (`table.update` overwrites in place), so it exists purely to drive a client-side "Adjusted by AI" affordance, not any kind of diff/revert capability.
`"messages": ("content",)` is in the `CORRECTABLE_FIELDS` registry and `persist_message` invokes `schedule_correction`/`schedule_modification` (sender = the user), so typing `@ai <instruction>` in a DM runs the AI modifier (default on + sync) and an enabled correction rewrites the content. Both send paths funnel through `routers/messages._finalize_and_broadcast(sender, message, request, client_id=None)`: it awaits any pending SYNC correction/modification futures stashed on `request.scope[PENDING_SCOPE_KEY]` (the same `PENDING_SCOPE_KEY`/`loop.run_in_executor` mechanism the HTTP `await_pending_corrections` middleware uses), RE-READS the message row, and broadcasts the FINAL (corrected/modified) content via `broadcast_message`. The HTTP `POST /send` and the WS `/ws` send path both call it; the WS path passes `request=websocket` to `persist_message` so the modifier stashes its executor future on the websocket scope and the handler awaits it directly (HTTP middleware does not run for websockets). A message with no `@ai` directive and correction off has nothing pending, so the broadcast is immediate with zero added latency. Frontend `static/js/MessagesLayout.js` reconciles the sender's own echo by `client_id` and replaces the optimistic bubble's `.rendered-content` with the server's authoritative `frame.content`, re-rendered - so the sender sees their `@ai` directive resolved in-place (and corrections become visible to the sender too). See "AI content correction" and "AI modifier".
## WS protocol frames
Client -> server: `{type:"send", receiver_uid, content, attachment_uids?, client_id}`, `{type:"typing", receiver_uid}` (throttled client-side), `{type:"read", with_uid}`. Server -> client: `{type:"ready", user_uid}` (sent first on connect), `{type:"message", uid, sender_uid, sender_username, sender_role, receiver_uid, content, created_at, time_ago, client_id, attachments, ai_processed}` (broadcast to BOTH sender and receiver sockets so multi-tab and the sender's own optimistic bubble reconcile via the echoed `client_id`; `ai_processed` is additive - `true` only when the broadcast content is the result of an awaited pending correction/modifier future, so an unmodified send is `false` with zero extra computation), `{type:"typing", from_uid}` (to the receiver only), `{type:"read", by_uid}` (read-receipt to the other user), `{type:"error", client_id, text}` (sender only, on a dropped send, e.g. the recipient blocked the sender). There is no `presence` frame on this socket - see below.
## Presence is NOT part of the messaging WS
Presence is handled entirely by the generic, cross-worker-correct mechanism documented in `devplacepy/services/CLAUDE.md` ("Online presence"): a single `users.last_seen` column, written by the `track_presence` HTTP middleware, read server-side at page load via `presence.is_online(user)`, and kept live client-side by `PresenceManager` (`static/js/PresenceManager.js`) subscribing any `[data-presence-uid]` element to the pub/sub topic `public.presence.{uid}`. The messages page's `#messages-presence` span carries `data-presence-uid`/`data-presence-last-seen` like every other avatar-presence site in the app - it is not messaging-specific code. An earlier design had WS-connect-based presence living in `message_hub` (`is_online`/`last_seen`, an `_announce_presence` helper, and a WS `presence` frame); that design was per-worker only and broke with more than one uvicorn worker, so it was removed outright. `message_hub` today tracks ONLY socket connections for message delivery - it has no presence state, and there is no `presence` frame anywhere in the current WS protocol (client or server). **Never re-implement WS-connect presence on this socket** - reuse `presence.is_online`, the `public.presence.{uid}` topic, and `PresenceManager`, exactly as every other presence surface in the app does.
Client -> server: `{type:"send", receiver_uid, content, attachment_uids?, client_id}`, `{type:"typing", receiver_uid}` (throttled client-side), `{type:"read", with_uid}`, `{type:"presence", with_uid}` (one-shot presence query). Server -> client: `{type:"ready", user_uid}` (sent first on connect), `{type:"message", uid, sender_uid, sender_username, receiver_uid, content, created_at, time_ago, client_id}` (broadcast to BOTH sender and receiver sockets so multi-tab and the sender's own optimistic bubble reconcile via the echoed `client_id`), `{type:"typing", from_uid}` (to the receiver only), `{type:"read", by_uid}` (read-receipt to the other user), `{type:"presence", user_uid, online, last_seen}` (announced to everyone in an open conversation with the user on connect/disconnect, and as a direct reply to a `presence` query). The WS `read` path reuses `mark_conversation_read` (the same `UPDATE messages SET read=1` SQL the page uses) and `clear_messages_cache`.
## Renderer integration (XSS control)
Live/echoed message bubbles are NEVER injected as raw HTML. `AppChat._buildBubble` (`static/js/components/AppChat.js`) creates a `<dp-content>` element and sets its `textContent` to the message body - never `innerHTML` - so the element's own client-side pipeline (emoji shortcode -> marked -> `DOMPurify.sanitize` -> highlight.js -> image/YouTube/autolink) renders it safely, exactly like every other live-content surface that uses `<dp-content>`. This gives Discord-style emoji, image and YouTube embeds, autolinking, and sanitization for free.
Live/echoed message bubbles are NEVER injected as raw HTML. `MessagesLayout.buildBubble` creates a `<div class="rendered-content" data-render>`, sets its `textContent` to the message body, then runs `contentRenderer.applyTo(el)` + marks `img:not(.avatar-img)` with `data-lightbox` + `contentRenderer.highlightAll()` - exactly the `ContentEnhancer.initContentRenderer` sequence (emoji shortcode -> marked -> DOMPurify.sanitize -> highlight.js -> image/YouTube/autolink). This gives Discord-style emoji, image and YouTube embeds, autolinking, and sanitization for free.
## Frontend
The messaging frontend is the single self-booting custom element `<dp-chat>` (`static/js/components/AppChat.js`), which replaced the old page-controller trio `MessagesLayout.js`/`MessagesSocket.js`/`MessageSearch.js` (all deleted) and `messages.css` (superseded by `static/css/chat.css`) - see `devplacepy/static/js/CLAUDE.md` for the component roster entry. `templates/messages.html` renders `<dp-chat mode="page" self-uid="..." with-uid="..." conversations-url="/messages/conversations" search-url="/messages/search" send-url="/messages/send" ws-url="/messages/ws" ai-indicator="true">` wrapping the SAME server-rendered `.messages-list`/`.messages-main`/`.messages-thread`/`.message-bubble` markup as before (no-JS/crawler fallback), which the component *adopts* on `connectedCallback` rather than discarding. It owns a `ChatSocket` (`static/js/chat/ChatSocket.js`, modeled on `PubSubClient.js`'s real exponential backoff instead of a flat-delay retry, same `4013` fast-path), sends over WS with an optimistic pending bubble keyed by `client_id` (the FIRST, unconditional branch of the incoming-frame handler matches `sender_uid === selfUid && client_id` against a pending bubble before any other branch - the fix for the old, permanently-disabled `appendOptimistic`), reconciles on the echoed `message` frame (falling to a `.failed`/tap-to-retry state after an 8s timeout with no echo), appends incoming messages live, auto-scrolls, throttles + shows the typing indicator, flips the read-receipt double-check, live-updates the conversation-list preview + unread dot + ordering, and groups consecutive same-sender messages within a 300s gap (`static/js/chat/MessageGrouping.js`, shared between the initial adopted history and the live-append path). If the socket is not open the send `<form method=POST>` submits normally (no-JS fallback). Presence in `mode="page"` needs no component code - the adopted markup keeps its `data-presence-uid` attributes, so the page-global `PresenceManager` already drives the dot/last-seen label; `<dp-chat mode="embed">` (no page-global `PresenceManager`) opens its own scoped `PubSubClient` subscription instead. An opt-in `ai-indicator="true"` attribute shows a brief "Adjusted by AI" caption when a reconciled echo's `ai_processed` flag is true and the content differs from what was locally typed - never a diff/revert UI. Styling is in `static/css/chat.css` using `variables.css` tokens only, responsive down to 360px, with a `theme`/`accent` attribute override mechanism (scoped inline custom properties on the component's own root) for future off-site embedding.
`MessagesSocket.js` (mirrors `DeviiSocket.js`: reconnect with backoff, `4013` silent fast-retry, `onReady` fires on the first server frame not raw open) points at `/messages/ws`. `MessagesLayout.js` (instantiated on `app` in `Application.js`) opens the socket on the messages page, sends over WS with an optimistic pending bubble keyed by `client_id`, reconciles on the echoed `message` frame, appends incoming messages live through the renderer, auto-scrolls, throttles + shows the typing indicator, flips the read-receipt double-check, renders the presence dot / last-seen in the header, and live-updates the conversation-list preview + unread dot + ordering. If the socket is not open the send `<form method=POST>` submits normally (no-JS fallback). The template carries `data-self-uid`/`data-other-uid`/`data-other-online`/`data-other-last-seen` on `.messages-layout`. Styling (presence dot, typing dots, read-receipt, unread dot, pending opacity) is in `messages.css` using design tokens only, responsive down to 360px.
## Attachments stream live
The single `message_frame` builder (`services/messaging/persist.py`, used by BOTH `broadcast_message` and the relay) fetches `get_attachments("message", uid)` and includes a slimmed list (`uid`/`url`/`thumbnail_url`/`is_image`/`is_video`/`is_audio`/`original_filename`/`file_size`/`mime_type`) on every frame via `_slim_attachment`. `is_audio` is derived identically to every other attachment surface in the app (`mime_type.startswith("audio/")`, already computed by `attachments._row_to_attachment` and simply forwarded here) - `AppChat._renderAttachments` mirrors `_attachment_display.html`'s type branches (image -> `img.gallery-thumb` marked `data-lightbox`+`data-full`, video -> `<video>`, audio -> `<audio controls>`, else a download link) - no `innerHTML`, so it stays XSS-safe, and a live-delivered audio attachment now renders identically to a page-refreshed one instead of falling back to a generic download link until reload. The sender's optimistic bubble shows an "Uploading attachment(s)..." placeholder until its own echo arrives and swaps in the real gallery. **Attachment-only messages (empty caption) are allowed on both send paths**: the WS `send` handler always allowed it (it reads `content` as a raw string with no Pydantic model), and the HTTP `POST /messages/send` form now does too (`MessageForm.content` is `min_length=0`) - `persist_message` accepts empty content when `attachment_uids` is non-empty (`if not content and not attachment_uids: return None`) and is the single place that rule lives. `dp-upload` (mode `attachment`, default `show-chips` off so only the `(N)` count badge shows) uploads each file to `/uploads/upload` and writes the uids into its hidden `attachment_uids` input; `AppChat._collectAttachments()` reads that before the send, then `dp-upload.clear()` resets it. The send button shows a spinner and is disabled while busy: `AppChat._refreshSendButton()` ORs `_uploading` (driven by the `dp-upload:busy` event) with `_pendingSends` (a map of in-flight send `client_id`s, cleared on the echo or an 8s safety timeout that flips the bubble to `.failed`/tap-to-retry), toggling `.is-sending` + `disabled` on `.messages-send-btn`.
The single `message_frame` builder (`services/messaging/persist.py`, used by BOTH `broadcast_message` and the relay) fetches `get_attachments("message", uid)` and includes a slimmed list (`uid`/`url`/`thumbnail_url`/`is_image`/`is_video`/`original_filename`/`file_size`/`mime_type`) on every frame. `MessagesLayout.renderAttachments` mirrors `_attachment_display.html` in the DOM (image -> `img.gallery-thumb` marked `data-lightbox`+`data-full`, video -> `<video>`, else a download link) - no `innerHTML`, so it stays XSS-safe. The sender's optimistic bubble shows an "Uploading attachment..." placeholder until its own echo arrives and swaps in the real gallery. **Attachment-only messages (empty caption) are allowed**: the content input dropped `required`, `sendViaSocket` sends when content OR attachments are present, and `persist_message` accepts empty content when `attachment_uids` is non-empty (`if not content and not attachment_uids: return None`). `dp-upload` (mode `attachment`) uploads each file to `/uploads/upload` and writes the uids into its hidden `attachment_uids` input; `collectAttachments()` reads that before the send, then `dp-upload.clear()` resets it. The chat uploader sets `hide-chips` so only the `(N)` count badge shows (no filename chips) - a generic `dp-upload` attribute. The send button shows a spinner and is disabled while busy: `MessagesLayout.refreshSendButton()` ORs `_uploading` (driven by the new `dp-upload:busy` event) with `_pendingSends` (a set of in-flight send `client_id`s, cleared on the echo or an 8s safety timeout), toggling `.is-sending` + `disabled` on `.messages-send-btn`.

View File

@ -3,13 +3,5 @@
from devplacepy.services.messaging.hub import message_hub
from devplacepy.services.messaging.persist import message_frame, persist_message
from devplacepy.services.messaging.relay import message_relay
from devplacepy.services.messaging.tickets import issue_ticket, redeem_ticket
__all__ = [
"issue_ticket",
"message_frame",
"message_hub",
"message_relay",
"persist_message",
"redeem_ticket",
]
__all__ = ["message_frame", "message_hub", "message_relay", "persist_message"]

View File

@ -30,7 +30,6 @@ def _slim_attachment(attachment: dict[str, Any]) -> dict[str, Any]:
"thumbnail_url": attachment.get("thumbnail_url"),
"is_image": bool(attachment.get("is_image")),
"is_video": bool(attachment.get("is_video")),
"is_audio": bool(attachment.get("is_audio")),
"original_filename": attachment.get("original_filename", ""),
"file_size": attachment.get("file_size", 0),
"mime_type": attachment.get("mime_type", ""),
@ -39,7 +38,7 @@ def _slim_attachment(attachment: dict[str, Any]) -> dict[str, Any]:
def message_frame(
message: dict[str, Any], sender_username: str, client_id: Optional[str] = None,
sender_role: Optional[str] = None, ai_processed: bool = False,
sender_role: Optional[str] = None,
) -> dict[str, Any]:
attachments = get_attachments("message", message["uid"])
return {
@ -54,7 +53,6 @@ def message_frame(
"time_ago": time_ago(message["created_at"]),
"client_id": client_id,
"attachments": [_slim_attachment(a) for a in attachments],
"ai_processed": ai_processed,
}

View File

@ -1,50 +0,0 @@
# retoor <retoor@molodetz.nl>
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from devplacepy.database import db, get_table
from devplacepy.utils import generate_uid
TICKET_KEY_BYTES = 24
TICKET_TTL_SECONDS = 30
def issue_ticket(user_uid: str) -> str:
tickets = get_table("ws_tickets")
token = secrets.token_hex(TICKET_KEY_BYTES)
now = datetime.now(timezone.utc)
expires_at = (now + timedelta(seconds=TICKET_TTL_SECONDS)).isoformat()
tickets.insert(
{
"uid": generate_uid(),
"token": token,
"user_uid": user_uid,
"created_at": now.isoformat(),
"expires_at": expires_at,
"used_at": None,
}
)
return token
def redeem_ticket(token: str) -> Optional[str]:
if not token:
return None
from sqlalchemy import text
now = datetime.now(timezone.utc).isoformat()
with db:
result = db.executable.execute(
text(
"UPDATE ws_tickets SET used_at = :now"
" WHERE token = :token AND used_at IS NULL AND expires_at >= :now"
),
{"now": now, "token": token},
)
if result.rowcount != 1:
return None
tickets = get_table("ws_tickets")
row = tickets.find_one(token=token)
return row.get("user_uid") if row else None

View File

@ -61,20 +61,6 @@ The gateway records one row per upstream call (chat, vision, passthrough) and su
**Financial data is admin-only everywhere.** Any monetary figure (USD cost, pricing, spend, limit) is restricted to administrators; members and guests see only the percentage of quota used - this rule is enforced consistently across the profile card, `ai_correction`/`ai_modifier` usage displays, and the Devii cost tools.
## Quota rules (`quota.py`, admin `/admin/gateway` "Quota rules" section)
**This caps `/openai/v1/*` itself, independent of Devii's own daily cap.** Devii's `devii_user_daily_usd`/`devii_guest_daily_usd`/`devii_admin_daily_usd` (documented in `devplacepy/services/devii/CLAUDE.md`) only gate turns that go *through* Devii. A caller hitting the gateway directly with their own `api_key` bypasses that entirely - `quota.py` is the root-level enforcement that closes this, checked in `GatewayService.handle()` right after owner/`app_reference` resolution and before every billed dispatch (chat, embeddings, images, passthrough; `GET /v1/models` is exempt, it makes no upstream call).
**Two layers, same shape as provider/model routing above.** Layer A is five flat `config_fields` on `GatewayService` (`gateway_default_user_daily_usd` $1.00, `gateway_default_admin_daily_usd` $0/unlimited, `gateway_default_guest_daily_usd` $0.05, `gateway_default_internal_daily_usd` $0/unlimited, `gateway_default_key_daily_usd` $0/unlimited - group **Quota**) applied per specific caller (`owner_id`) when no rule matches; internal/key default unlimited so shipping this never starts blocking DevPlace's own news/bots/Devii-guest/correction traffic on the internal key. Layer B is the `gateway_quota_rules` table (`ensure_tables()`, called from `init_db()` alongside `routing.ensure_tables()`; hard CRUD, not in `SOFT_DELETE_TABLES`, cross-worker cache-invalidated under the `"gateway_quota"` name): each row scopes by **any combination** of `owner_kind` (internal/key/user/admin/anonymous - DevPlace's only "roles" here), a specific `owner_id`, and `app_reference` (the `X-App-Reference` label), each nullable = wildcard; a `QuotaRuleIn` Pydantic validator rejects a rule with all three blank (that belongs in Layer A). `quota.resolve(owner_kind, owner_id, app_reference, cfg)` gathers every active rule whose non-null dimensions all equal the request, picks the one with the most non-null dimensions (ties broken toward the smaller limit, unlimited `0` never wins a tie against a finite cap), and returns `(limit_usd, scope, rule)` where `scope` is the exact `(owner_kind, owner_id, app_reference)` triple - each possibly `None` - that spend must be summed over. Layer A is internally just the maximally-specific implicit scope `(owner_kind, owner_id, None)`, so one code path (`quota.spent_24h(*scope)`, a plain `SUM(cost_usd)` over `gateway_usage_ledger` filtered by whichever scope dimensions are non-null) serves both layers.
**A wildcard dimension means a shared pool, by design.** A rule scoped only by `app_reference` caps that app's combined spend across every caller using it; a rule scoped only by `owner_kind` caps that whole role's combined spend. Pin `owner_id` to get a true per-caller cap (the Layer A default's own behavior). `anonymous`/`internal`/`key` owner_ids are already fixed constants (`"anonymous"`/`"devii"`/`"access"`, from `resolve_owner()`), not per-caller identities, so any cap on those kinds is inherently pooled - there is no per-guest identity at this layer (unlike Devii's own guest-cookie-scoped ledger).
**No lock, no hold, bounded overshoot by design - this is deliberate, not an oversight.** Cost is only known after the upstream call returns, so a true atomic pre-authorization would need a reserve-then-reconcile ("hold") mechanism, and a bug in releasing a hold is exactly the kind of thing that gets a caller stuck forever. Instead this mirrors Devii's own already-shipped mechanism exactly: read the 24h sum, compare, `raise HTTPException(429, ...)` if already at/over - a single `SELECT` and a conditional raise, nothing held, nothing to leak, structurally impossible to deadlock. The tradeoff is a small, bounded overshoot (at most a few concurrent in-flight calls' worth of cost past the cap before the next request sees the updated sum and blocks) - acceptable and industry-standard for a cost whose exact size isn't known until the call finishes, and it is the property actually being enforced: once tripped, every subsequent separate request stays blocked until the 24h window rolls off or an admin adjusts the rule.
**429 body never carries a dollar figure**, admin or not (`{"detail": "AI gateway daily quota exceeded"}`) - mirrors Devii's own over-limit WS message, which likewise never states a number. The admin-only services log line and the `ai.quota.exceeded` audit row (`GatewayService._audit_quota_exceeded`, reusing `usage.audit_actor_for`) do carry the spend/limit/matched-rule-uid, since those are admin-only surfaces.
**CRUD.** Admin JSON at `/admin/gateway/quota-rules` (`routers/admin/gateway_configs.py`, list returns each rule's live `spent_24h_usd` plus the Layer A defaults for context), audited `gateway.quota_rule.update`/`gateway.quota_rule.delete` (category `ai`, both already in `events.md`), rendered in the **Quota rules** section of `/admin/gateway` (`GatewayAdmin.js`, mirrors the providers/models CRUD tables). Devii tools `gateway_quota_rules`/`gateway_quota_rule_set`/`gateway_quota_rule_delete` (`requires_admin=True`, delete is `CONFIRM_REQUIRED`) proxy the same endpoints via `handler="http"`, same as the provider/model tools. CLI: `devplace gateway quota list|set|delete`.
## Image generation
`POST /openai/v1/images/generations` exposes an OpenAI-compatible image-generation endpoint. Clients send the generic model `molodetz-img-small` (`config.INTERNAL_IMAGE_MODEL`), which `handle_images` remaps to `gateway_image_model` exactly like chat remaps `molodetz` -> `gateway_model` (also remapped when `gateway_force_model` is on or the model is empty or `molodetz-img`). It defaults to OpenRouter's `black-forest-labs/flux-1.1-pro` at `https://openrouter.ai/api/v1/images/generations` (`config.IMAGE_*_DEFAULT`, $0.04 per image fallback). `handle_images` mirrors `handle_embeddings`: build the payload, forward via `_send`, and record one ledger row. The config fields are the **Images** group (`gateway_image_enabled` default on, `gateway_image_url`, `gateway_image_model`, `gateway_image_key`) plus the Pricing-group `gateway_image_price_per_call`. `effective_config()` falls the image key back to `gateway_api_key` then `OPENROUTER_API_KEY`. Usage is recorded with **`backend="image"`**; `usage.compute_cost` adds an `image` branch (flat per-call, native OpenRouter `cost` still preferred via `extract_image_usage`). `routing.image_overlay` resolves per-route provider/url/key and uses `price_input_per_m` as the per-image price. `routing.seed_default_image_routes()` (from `migrate_ai_gateway_settings`) idempotently seeds `molodetz-img-small` -> Flux on the `openrouter` provider when `OPENROUTER_API_KEY` is set. When `gateway_image_enabled` is off the endpoint returns 503 with no ledger row.

View File

@ -11,7 +11,6 @@ import httpx
from fastapi.responses import JSONResponse, Response, StreamingResponse
from devplacepy import stealth
from devplacepy.services.background import background
from devplacepy.services.openai_gateway import config
from devplacepy.services.openai_gateway.reliability import CircuitBreaker, retry_send
from devplacepy.services.openai_gateway.routing import (
@ -34,19 +33,6 @@ from devplacepy.services.openai_gateway.vision import VisionAugmenter, VisionCac
logger = logging.getLogger(__name__)
def _notify_gateway_status(is_open: bool) -> None:
from devplacepy.database import get_table
from devplacepy.utils import create_notification
message = (
"AI gateway circuit breaker opened - upstream calls are being rejected"
if is_open
else "AI gateway circuit breaker closed - upstream calls have resumed"
)
for admin in get_table("users").find(role="Admin"):
create_notification(admin["uid"], "system", message, "gateway", "/admin/services/openai")
def _fake_stream(data: dict, model: str, include_usage: bool = False):
chunk_id = data.get("id", f"chatcmpl-{uuid.uuid4().hex[:12]}")
created = data.get("created", int(time.time()))
@ -233,24 +219,15 @@ class GatewayRuntime:
self.last_latency_ms = int(timing["upstream_latency_ms"])
if exc is not None:
self.errors += 1
was_open = self._breaker.is_open
self._breaker.record_failure()
if not was_open and self._breaker.is_open:
background.submit(_notify_gateway_status, True)
log(f"{method} {url} connection failed after {attempts} attempt(s): {exc}")
return None, exc, timing
self.last_status = resp.status_code
if resp.status_code >= 500:
self.errors += 1
was_open = self._breaker.is_open
self._breaker.record_failure()
if not was_open and self._breaker.is_open:
background.submit(_notify_gateway_status, True)
else:
was_open = self._breaker.is_open
self._breaker.record_success()
if was_open and not self._breaker.is_open:
background.submit(_notify_gateway_status, False)
timing["retry_succeeded"] = attempts > 1
return resp, None, timing

View File

@ -1,317 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
import re
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.database import bump_cache_version, db, get_table, sync_local_cache
logger = logging.getLogger(__name__)
RULES_TABLE = "gateway_quota_rules"
CACHE_NAME = "gateway_quota"
APP_REFERENCE_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]{1,30}$")
OWNER_KINDS = ("internal", "key", "user", "admin", "anonymous")
FIELD_DEFAULT_USER = "gateway_default_user_daily_usd"
FIELD_DEFAULT_ADMIN = "gateway_default_admin_daily_usd"
FIELD_DEFAULT_GUEST = "gateway_default_guest_daily_usd"
FIELD_DEFAULT_INTERNAL = "gateway_default_internal_daily_usd"
FIELD_DEFAULT_KEY = "gateway_default_key_daily_usd"
_DEFAULT_FIELD_BY_KIND = {
"user": FIELD_DEFAULT_USER,
"admin": FIELD_DEFAULT_ADMIN,
"anonymous": FIELD_DEFAULT_GUEST,
"internal": FIELD_DEFAULT_INTERNAL,
"key": FIELD_DEFAULT_KEY,
}
_QUOTA_CACHE: dict = {}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def ensure_tables() -> None:
db.query(
"CREATE TABLE IF NOT EXISTS "
+ RULES_TABLE
+ " (id INTEGER PRIMARY KEY, uid TEXT, owner_kind TEXT, owner_id TEXT, "
"app_reference TEXT, limit_usd REAL DEFAULT 0, is_active INTEGER DEFAULT 1, "
"label TEXT, created_by TEXT, created_at TEXT, updated_at TEXT)"
)
try:
db.query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_quota_rules_uid ON "
+ RULES_TABLE
+ " (uid)"
)
db.query(
"CREATE INDEX IF NOT EXISTS idx_gateway_quota_rules_lookup ON "
+ RULES_TABLE
+ " (owner_kind, owner_id, app_reference)"
)
except Exception as exc:
logger.warning("gateway quota rule index creation failed: %s", exc)
class QuotaRuleIn(BaseModel):
owner_kind: Optional[str] = None
owner_id: Optional[str] = Field(default=None, max_length=64)
app_reference: Optional[str] = Field(default=None, max_length=30)
limit_usd: float = Field(default=0.0, ge=0)
is_active: bool = True
label: str = Field(default="", max_length=200)
@field_validator("owner_kind")
@classmethod
def _clean_owner_kind(cls, value: Optional[str]) -> Optional[str]:
if not value:
return None
value = value.strip().lower()
if value not in OWNER_KINDS:
raise ValueError(f"owner_kind must be one of {', '.join(OWNER_KINDS)}")
return value
@field_validator("owner_id")
@classmethod
def _clean_owner_id(cls, value: Optional[str]) -> Optional[str]:
if not value:
return None
return value.strip()
@field_validator("app_reference")
@classmethod
def _clean_app_reference(cls, value: Optional[str]) -> Optional[str]:
if not value:
return None
value = value.strip()
if not APP_REFERENCE_PATTERN.match(value):
raise ValueError("app_reference must match ^[a-zA-Z0-9_.-]{1,30}$")
return value
@field_validator("label")
@classmethod
def _clean_label(cls, value: str) -> str:
return (value or "").strip()
@model_validator(mode="after")
def _require_a_dimension(self) -> "QuotaRuleIn":
if self.owner_kind is None and self.owner_id is None and self.app_reference is None:
raise ValueError(
"At least one of owner_kind, owner_id, or app_reference is required - "
"an unscoped cap belongs in the global default fields, not a rule"
)
return self
@dataclass(frozen=True)
class QuotaRule:
uid: str
owner_kind: Optional[str]
owner_id: Optional[str]
app_reference: Optional[str]
limit_usd: float
is_active: bool
label: str
created_by: str
created_at: str
updated_at: str
@property
def specificity(self) -> int:
return sum(
1 for v in (self.owner_kind, self.owner_id, self.app_reference) if v is not None
)
def matches(self, owner_kind: str, owner_id: str, app_reference: str) -> bool:
if not self.is_active:
return False
if self.owner_kind is not None and self.owner_kind != owner_kind:
return False
if self.owner_id is not None and self.owner_id != owner_id:
return False
if self.app_reference is not None and self.app_reference != app_reference:
return False
return True
def as_dict(self) -> dict:
return {
"uid": self.uid,
"owner_kind": self.owner_kind,
"owner_id": self.owner_id,
"app_reference": self.app_reference,
"limit_usd": self.limit_usd,
"is_active": self.is_active,
"label": self.label,
"created_by": self.created_by,
"created_at": self.created_at,
"updated_at": self.updated_at,
"specificity": self.specificity,
}
def _row_to_rule(row: dict) -> QuotaRule:
return QuotaRule(
uid=str(row.get("uid") or ""),
owner_kind=row.get("owner_kind") or None,
owner_id=row.get("owner_id") or None,
app_reference=row.get("app_reference") or None,
limit_usd=float(row.get("limit_usd") or 0.0),
is_active=bool(row.get("is_active", 1)),
label=str(row.get("label") or ""),
created_by=str(row.get("created_by") or ""),
created_at=str(row.get("created_at") or ""),
updated_at=str(row.get("updated_at") or ""),
)
def _load() -> list[QuotaRule]:
sync_local_cache(CACHE_NAME, _QUOTA_CACHE)
if "rules" not in _QUOTA_CACHE:
rules: list[QuotaRule] = []
try:
if RULES_TABLE in db.tables:
for row in get_table(RULES_TABLE).all():
if row.get("uid"):
rules.append(_row_to_rule(row))
except Exception as exc:
logger.warning("gateway quota rule load failed: %s", exc)
_QUOTA_CACHE["rules"] = rules
return _QUOTA_CACHE["rules"]
class QuotaRuleStore:
def list(self) -> list[dict]:
rules = sorted(_load(), key=lambda r: (-r.specificity, r.created_at))
return [r.as_dict() for r in rules]
def get(self, uid: str) -> Optional[QuotaRule]:
if not uid:
return None
for rule in _load():
if rule.uid == uid:
return rule
return None
def count(self) -> int:
return len(_load())
def set(
self, payload: QuotaRuleIn, *, uid: Optional[str] = None, created_by: str = ""
) -> dict:
ensure_tables()
table = get_table(RULES_TABLE)
existing = table.find_one(uid=uid) if uid else None
record: dict = {
"owner_kind": payload.owner_kind,
"owner_id": payload.owner_id,
"app_reference": payload.app_reference,
"limit_usd": payload.limit_usd,
"is_active": 1 if payload.is_active else 0,
"label": payload.label,
"updated_at": _now(),
}
if existing:
record["uid"] = existing["uid"]
record["created_by"] = existing.get("created_by") or created_by
record["created_at"] = existing.get("created_at") or _now()
table.update({**record, "id": existing["id"]}, ["id"])
else:
record["uid"] = uid or uuid.uuid4().hex
record["created_by"] = created_by
record["created_at"] = _now()
table.insert(record)
bump_cache_version(CACHE_NAME)
_QUOTA_CACHE.clear()
saved = self.get(record["uid"])
return saved.as_dict() if saved else record
def remove(self, uid: str) -> bool:
uid = (uid or "").strip()
if not uid or RULES_TABLE not in db.tables:
return False
removed = int(get_table(RULES_TABLE).delete(uid=uid))
if removed:
bump_cache_version(CACHE_NAME)
_QUOTA_CACHE.clear()
return bool(removed)
quota_rule_store = QuotaRuleStore()
def default_limit(owner_kind: str, cfg: dict) -> float:
field = _DEFAULT_FIELD_BY_KIND.get(owner_kind, FIELD_DEFAULT_USER)
return float(cfg.get(field, 0.0) or 0.0)
def resolve(
owner_kind: str, owner_id: str, app_reference: str, cfg: dict
) -> tuple[float, tuple[Optional[str], Optional[str], Optional[str]], Optional[QuotaRule]]:
matches = [r for r in _load() if r.matches(owner_kind, owner_id, app_reference)]
if matches:
def _sort_key(rule: QuotaRule):
tie = float("inf") if rule.limit_usd == 0 else rule.limit_usd
return (-rule.specificity, tie)
best = sorted(matches, key=_sort_key)[0]
return best.limit_usd, (best.owner_kind, best.owner_id, best.app_reference), best
return default_limit(owner_kind, cfg), (owner_kind, owner_id, None), None
def resolve_for_owner(
owner_kind: str, owner_id: str, cfg: dict
) -> tuple[float, tuple[Optional[str], Optional[str], None], Optional[QuotaRule]]:
matches = [
r
for r in _load()
if r.app_reference is None and r.matches(owner_kind, owner_id, "")
]
if matches:
def _sort_key(rule: QuotaRule):
tie = float("inf") if rule.limit_usd == 0 else rule.limit_usd
return (-rule.specificity, tie)
best = sorted(matches, key=_sort_key)[0]
return best.limit_usd, (best.owner_kind, best.owner_id, None), best
return default_limit(owner_kind, cfg), (owner_kind, owner_id, None), None
def spent_24h(
owner_kind: Optional[str], owner_id: Optional[str], app_reference: Optional[str]
) -> float:
from devplacepy.services.openai_gateway.usage import GATEWAY_LEDGER
if GATEWAY_LEDGER not in db.tables:
return 0.0
clauses = ["created_at >= :cutoff"]
params: dict = {
"cutoff": (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
}
if owner_kind is not None:
clauses.append("owner_kind = :owner_kind")
params["owner_kind"] = owner_kind
if owner_id is not None:
clauses.append("owner_id = :owner_id")
params["owner_id"] = owner_id
if app_reference is not None:
clauses.append("app_reference = :app_reference")
params["app_reference"] = app_reference
where = " AND ".join(clauses)
rows = list(
db.query(
f"SELECT COALESCE(SUM(cost_usd), 0) AS spent FROM {GATEWAY_LEDGER} WHERE {where}",
**params,
)
)
return float(rows[0].get("spent") or 0.0) if rows else 0.0

View File

@ -11,7 +11,7 @@ from fastapi.responses import JSONResponse
from devplacepy.database import get_int_setting
from devplacepy.services.base import BaseService, ConfigField
from devplacepy.services.openai_gateway import config, quota
from devplacepy.services.openai_gateway import config
from devplacepy.services.openai_gateway.analytics import summary_metrics
from devplacepy.services.openai_gateway.gateway import GatewayRuntime
from devplacepy.services.openai_gateway.routing import model_store
@ -262,58 +262,6 @@ class GatewayService(BaseService):
"with this key. Clear it and restart to rotate.",
group="Access",
),
ConfigField(
quota.FIELD_DEFAULT_USER,
"Default per-user daily cap ($)",
type="float",
default=1.0,
minimum=0,
help="Rolling 24h cap applied to a signed-in member with no matching quota rule. "
"0 = unlimited. Overridable per user/app-reference on /admin/gateway.",
group="Quota",
),
ConfigField(
quota.FIELD_DEFAULT_ADMIN,
"Default per-admin daily cap ($)",
type="float",
default=0.0,
minimum=0,
help="Rolling 24h cap applied to an administrator with no matching quota rule. "
"0 = unlimited (the default - admins are exempt unless a rule says otherwise).",
group="Quota",
),
ConfigField(
quota.FIELD_DEFAULT_GUEST,
"Default per-guest daily cap ($)",
type="float",
default=0.05,
minimum=0,
help="Rolling 24h cap applied to an unauthenticated caller with no matching quota "
"rule (only reachable when authentication is not required). 0 = unlimited.",
group="Quota",
),
ConfigField(
quota.FIELD_DEFAULT_INTERNAL,
"Default per-internal-key daily cap ($)",
type="float",
default=0.0,
minimum=0,
help="Rolling 24h cap applied to calls authenticated with the auto-generated internal "
"key (DevPlace's own services: news, bots, Devii guests, correction/modifier). "
"0 = unlimited (the default - do not cap this without a matching quota rule, or "
"internal platform traffic will start failing).",
group="Quota",
),
ConfigField(
quota.FIELD_DEFAULT_KEY,
"Default per-access-key daily cap ($)",
type="float",
default=0.0,
minimum=0,
help="Rolling 24h cap applied to calls authenticated with the static access key. "
"0 = unlimited by default (it is an admin-provisioned trusted secret).",
group="Quota",
),
ConfigField(
"gateway_price_cache_hit_per_m",
"Chat price cache-hit / 1M ($)",
@ -515,37 +463,6 @@ class GatewayService(BaseService):
return (kind, user.get("uid") or "unknown")
return ("anonymous", "anonymous")
def _audit_quota_exceeded(
self,
owner_kind: str,
owner_id: str,
app_reference: str,
spent: float,
limit: float,
rule,
) -> None:
from devplacepy.services.audit import record as audit
from devplacepy.services.openai_gateway.usage import audit_actor_for
actor_kind, actor_uid, actor_role = audit_actor_for(owner_kind, owner_id)
audit.record_system(
"ai.quota.exceeded",
actor_kind=actor_kind,
actor_uid=actor_uid,
actor_role=actor_role,
origin="api",
result="denied",
summary=f"AI gateway call by {owner_kind}/{owner_id} blocked - 24h quota reached",
metadata={
"owner_kind": owner_kind,
"owner_id": owner_id,
"app_reference": app_reference,
"spent_usd": round(spent, 6),
"limit_usd": limit,
"rule_uid": rule.uid if rule else None,
},
)
def _models_response(self) -> JSONResponse:
created = int(time.time())
seen: set = set()
@ -584,17 +501,6 @@ class GatewayService(BaseService):
)
if subpath == "models" and request.method == "GET":
return self._models_response()
limit, scope, rule = quota.resolve(owner[0], owner[1], app_reference, cfg)
if limit > 0:
spent = quota.spent_24h(*scope)
if spent >= limit:
self.log(
f"Rejected {owner[0]}:{owner[1]} app={app_reference}: "
f"quota exceeded (${spent:.4f}/${limit:.4f}"
f"{' rule ' + rule.uid if rule else ' default'})"
)
self._audit_quota_exceeded(owner[0], owner[1], app_reference, spent, limit, rule)
raise HTTPException(status_code=429, detail="AI gateway daily quota exceeded")
if subpath == "chat/completions" and request.method == "POST":
try:
body = await request.json()

View File

@ -1,42 +0,0 @@
# CSS system (`devplacepy/static/css/`)
This file documents the CSS conventions for every stylesheet in this directory. Claude Code auto-loads it when a file under `static/css/` is read or edited. The member-facing style guide lives at `/docs/styles*.html`; keep both in sync when a convention changes.
## Tokens (`variables.css` is the single source of truth)
- **Never hardcode a themable value.** Colours, spacing (`--space-xs`...`--space-2xl`), radii, shadows, fonts, layout measures, and z-indexes are always `var(--token)` references. A missing value means adding a token to `variables.css`, not inlining a literal.
- **Never give a global token a fallback.** `var(--accent)`, never `var(--accent, #hex)`. A fallback silently masks a dead or misspelled token - the page keeps rendering from a value nobody maintains. This was a real bug class: `var(--topic-discussion, #ef4444)`, `var(--bg-code, #1a1a2e)`, `var(--accent, #6366f1)` referenced tokens that never existed and shipped a foreign palette via their fallbacks.
- **Accent tints compose the channel token:** `rgba(var(--accent-rgb), 0.3)`, never a literal `rgba(255, 107, 53, ...)`. The raw accent literal exists only at its definition in `variables.css`.
- **Status is semantic:** `--success`/`--warning`/`--danger`/`--info` for state, `--topic-*` only for topic badges. Never repurpose a topic token as a status colour or vice versa.
- **One name per value.** Do not add alias tokens (`--space-base` and `--shadow-md` were removed for duplicating `--space-sm` and `--shadow`).
## File-scoped palettes
A feature with its own semantic palette defines it ONCE as a custom-property block at the top of its own stylesheet and references only those properties below: `devii.css` scopes `--devii-*` on `devii-terminal`; `isslop.css` scopes `--isslop-*` (solid + `-rgb` channel pairs for alpha composition) on `:root` (the file loads only on its own pages). Raw hex values may exist in exactly two places: `variables.css` and these blocks. A repeated hex or `rgba()` literal below a token block is a defect.
## Stacking (`--z-*` tokens, two bands)
Every `z-index` is a token from `variables.css`; literal values are forbidden except small local ladders (`z-index: 1`/`2`) inside an already-stacked component.
| Band | Order (bottom to top) |
|------|-----------------------|
| Content | `--z-fab` 900, `--z-nav-overlay` 998, `--z-nav-panel` 999, `--z-nav` 1000, `--z-nav-drop` 1001, `--z-modal` 2000, `--z-popover` 2100, `--z-lightbox` 10000 |
| Chrome | `--z-chrome-fw` 2147480000, `--z-chrome-devii` 2147483000, `--z-chrome-toast` 2147483400, `--z-chrome-menu` 2147483500, `--z-chrome-dialog` 2147483647 |
The chrome band is **user-CSS-proof by design**: per-user injected CSS (the customization feature) must never stack above floating windows, the Devii terminal, toasts, the context menu, or confirm dialogs, so they live at the top of the integer range AND carry `will-change: transform` (the layer-promotion group in `base.css` - any new always-on fixed chrome element must join both). Toasts sit in the chrome band deliberately: they fire over open modals and stay clickable. The Devii tutorial-overlay ladder (2147483600-602 in `devii.css`) is an intentional local sequence between `--z-chrome-menu` and the devii avatar. A new stacked element picks the matching token or adds one between two stops in `variables.css` - never a number at the use site.
## Breakpoints (exact, closed set)
`@media (max-width: 1024px | 768px | 480px | 360px)` plus the capability/preference queries `(hover: none) and (pointer: coarse)` and `(prefers-reduced-motion: reduce)`. Nothing else. The historical one-offs (900/720/640/600/560/520) were consolidated by snapping each UP to the next canonical stop - snap-up is the required direction because collapsing earlier can never overflow, while snapping down leaves a viewport band the compact styles no longer protect. Element `max-width` values (content measures like `max-width: 720px` on an article column) are not breakpoints and are unaffected by this rule.
## Reduced motion
One global rule at the end of `base.css` collapses every animation/transition to `0.01ms` under `prefers-reduced-motion: reduce`. Its four `!important`s are the documented exception to the no-`!important` rule: a zero-specificity `*` selector cannot otherwise beat any class rule, and per-rule opt-outs would need editing every declaration. Durations are near-zero rather than `none` so JS `transitionend`/`animationend` handlers still fire. Pages add their own reduced-motion query ONLY when content must change (e.g. `deepsearch.css` stopping an indeterminate bar), never to re-disable motion.
## Layout rules
- Flexbox + CSS Grid with `gap` only; floats for layout are forbidden.
- Approved page layouts and the shared shell are documented in `/docs/styles-layout.html`; a page supplies exactly one layout container inside `.page`.
- Every fluid grid/flex column sets `min-width: 0`.
- `!important` is allowed only for: the `.hidden`/`[hidden]` display utilities, the global reduced-motion rule, and the devii-avatar third-party-beating override. Anything else is a specificity problem to be fixed structurally.
- Page-specific CSS lives in its own `static/css/*.css` loaded via `{% block extra_head %}`, never an inline `<style>` block; shared component styles live once (`components.css`, `feed.css` vote buttons) and are never redefined per page.

View File

@ -151,7 +151,7 @@
}
.admin-role-badge.admin {
background: rgba(var(--accent-rgb), 0.15);
background: rgba(255, 107, 53, 0.15);
color: var(--accent);
}

View File

@ -23,7 +23,7 @@
}
.ai-usage-section h3 {
margin: 0 0 var(--space-sm);
margin: 0 0 var(--space-base);
font-size: 0.9375rem;
color: var(--text-primary);
}

View File

@ -61,7 +61,7 @@
.audit-badge-devii {
margin-left: 0.25rem;
background: rgba(var(--accent-rgb), 0.15);
background: rgba(255, 107, 53, 0.15);
color: var(--accent);
}
@ -161,7 +161,7 @@
color: var(--success);
}
@media (max-width: 768px) {
@media (max-width: 640px) {
.audit-detail-grid {
grid-template-columns: 1fr;
gap: 0.125rem 0;

View File

@ -41,7 +41,7 @@
gap: 1rem;
}
@media (max-width: 768px) {
@media (max-width: 720px) {
.awards-grid {
grid-template-columns: 1fr;
}

View File

@ -597,7 +597,7 @@ img {
height: var(--nav-height);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
z-index: var(--z-nav);
z-index: 1000;
display: flex;
align-items: center;
}
@ -659,9 +659,9 @@ img {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 0.25rem 0;
z-index: var(--z-nav-drop);
z-index: 1001;
}
.topnav-user-dropdown:hover .dropdown-menu,
.topnav-user-dropdown.open .dropdown-menu { display: block; }
@ -678,9 +678,9 @@ img {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 0.25rem 0;
z-index: var(--z-nav-drop);
z-index: 1001;
}
.topnav-tools-dropdown:hover .dropdown-menu,
.topnav-tools-dropdown.open .dropdown-menu { display: block; }
@ -709,7 +709,7 @@ img {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: var(--z-nav-overlay);
z-index: 998;
}
.topnav-mobile-panel {
@ -719,7 +719,7 @@ img {
right: 0;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
z-index: var(--z-nav-panel);
z-index: 999;
max-height: 0;
overflow: hidden;
}
@ -1011,7 +1011,7 @@ body:has(.page-messages) {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: var(--z-modal);
z-index: 2000;
align-items: center;
justify-content: center;
}
@ -1130,7 +1130,7 @@ body:has(.page-messages) {
position: absolute;
top: 0;
left: 0;
z-index: var(--z-modal);
z-index: 2000;
transform: translateY(-150%);
padding: 0.75rem 1.25rem;
background: var(--accent);
@ -1349,14 +1349,3 @@ body:has(.page-messages) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@ -92,7 +92,7 @@
text-overflow: ellipsis;
}
@media (max-width: 768px) {
@media (max-width: 600px) {
.bots-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;

View File

@ -5,7 +5,7 @@
position: fixed;
inset: 0;
background: var(--overlay-dark);
z-index: var(--z-chrome-dialog);
z-index: 2147483647;
align-items: center;
justify-content: center;
}
@ -67,7 +67,7 @@
.context-menu {
display: none;
position: fixed;
z-index: var(--z-chrome-menu);
z-index: 2147483500;
min-width: 180px;
max-width: 280px;
background: var(--bg-card);
@ -248,7 +248,7 @@ dp-upload[hidden] {
position: fixed;
bottom: max(1rem, env(safe-area-inset-bottom));
right: max(1rem, env(safe-area-inset-right));
z-index: var(--z-chrome-toast);
z-index: 1100;
display: flex;
flex-direction: column;
gap: 0.5rem;

View File

@ -137,7 +137,7 @@
color: var(--ai-color-muted);
}
@media (max-width: 768px) {
@media (max-width: 520px) {
.ai-actions {
flex-direction: column;
}

View File

@ -5,7 +5,7 @@
.cm-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.125rem var(--space-sm);
padding: 0.125rem var(--space-base);
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
@ -105,7 +105,7 @@
.ci-schedules { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
.ci-schedule { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
.ci-schedule-action { font-weight: 600; text-transform: uppercase; font-size: 0.7rem; padding: 0.125rem var(--space-sm); border-radius: 10px; background: var(--accent-light); color: var(--accent); }
.ci-schedule-action { font-weight: 600; text-transform: uppercase; font-size: 0.7rem; padding: 0.125rem var(--space-base); border-radius: 10px; background: var(--accent-light); color: var(--accent); }
.ci-schedule-next { color: var(--text-secondary); font-family: var(--font-mono); }
.ci-schedule button { margin-left: auto; }

View File

@ -604,7 +604,7 @@ dp-deepsearch-chat {
cursor: pointer;
}
@media (max-width: 1024px) {
@media (max-width: 900px) {
.ds-layout,
.ds-session {
grid-template-columns: 1fr;
@ -616,7 +616,7 @@ dp-deepsearch-chat {
}
}
@media (max-width: 768px) {
@media (max-width: 560px) {
.ds-phase-name {
display: none;
}

View File

@ -12,19 +12,12 @@ devii-terminal {
--devii-bar-bg: #0b0b0b;
--devii-border: #1c1f24;
--devii-code-bg: #11151a;
--devii-code-fg: #7ee787;
--devii-hover-bg: #1b1f25;
--devii-toolbar-bg: #060606;
--devii-tok-kw: #ff7b72;
--devii-tok-str: #a5d6ff;
--devii-tok-num: #f2cc60;
--devii-tok-lit: #d2a8ff;
--devii-link: #58a6ff;
--devii-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
--devii-radius: 10px;
--devii-font: "SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
--devii-font-size: 14px;
--devii-z: var(--z-chrome-devii);
--devii-z: 2147483000;
position: fixed;
inset: 0;
@ -205,7 +198,7 @@ devii-terminal .devii-winctl button[hidden] {
devii-terminal .devii-winctl button:hover {
color: var(--devii-fg);
background: var(--devii-hover-bg);
background: #1b1f25;
}
devii-terminal .devii-winctl button[data-win="close"]:hover {
@ -218,7 +211,7 @@ devii-terminal .devii-toolbar {
display: flex;
gap: 6px;
padding: 6px 10px;
background: var(--devii-toolbar-bg);
background: #060606;
border-bottom: 1px solid var(--devii-border);
flex: 0 0 auto;
}
@ -362,7 +355,7 @@ devii-terminal .md-code {
background: var(--devii-code-bg);
padding: 1px 5px;
border-radius: 4px;
color: var(--devii-code-fg);
color: #7ee787;
}
devii-terminal .md-codewrap {
position: relative;
@ -453,20 +446,20 @@ devii-terminal .md-pre code {
color: var(--devii-fg);
}
devii-terminal .tok-kw {
color: var(--devii-tok-kw);
color: #ff7b72;
}
devii-terminal .tok-str {
color: var(--devii-tok-str);
color: #a5d6ff;
}
devii-terminal .tok-com {
color: var(--devii-dim);
font-style: italic;
}
devii-terminal .tok-num {
color: var(--devii-tok-num);
color: #f2cc60;
}
devii-terminal .tok-lit {
color: var(--devii-tok-lit);
color: #d2a8ff;
}
devii-terminal .devii-agent .md-table {
border-collapse: collapse;
@ -509,7 +502,7 @@ devii-terminal.devii-mobile .devii-winctl button[data-win="fullscreen"] {
display: none;
}
@media (max-width: 768px) {
@media (max-width: 640px) {
devii-terminal {
--devii-font-size: 13px;
}

View File

@ -311,7 +311,7 @@ dp-docs-chat {
cursor: default;
}
@media (max-width: 768px) {
@media (max-width: 640px) {
.docs-chat-bubble {
max-width: 100%;
}

View File

@ -571,7 +571,7 @@ pre.code-pre > .code-gutter {
.docs-search-input:focus {
outline: none;
border-color: var(--accent);
border-color: var(--accent, #6366f1);
}
.docs-search-bigform {
@ -609,7 +609,7 @@ pre.code-pre > .code-gutter {
.docs-search-result-title {
font-size: 1.0625rem;
font-weight: 600;
color: var(--accent);
color: var(--accent, #6366f1);
}
.docs-search-snippet {
@ -620,7 +620,7 @@ pre.code-pre > .code-gutter {
}
.docs-search-snippet mark {
background: var(--accent);
background: var(--accent, #6366f1);
color: var(--white);
padding: 0 0.125rem;
border-radius: 3px;

View File

@ -194,13 +194,13 @@
position: absolute;
inset: 0;
width: var(--poll-pct, 0);
background: rgba(var(--accent-rgb), 0.18);
background: rgba(255, 107, 53, 0.18);
transition: width 0.3s ease;
z-index: 0;
}
.poll-option.chosen .poll-option-bar {
background: rgba(var(--accent-rgb), 0.32);
background: rgba(255, 107, 53, 0.32);
}
.poll-option-label {

View File

@ -373,7 +373,7 @@
align-items: center;
justify-content: center;
box-shadow: var(--glow-accent);
z-index: var(--z-fab);
z-index: 900;
border: none;
cursor: pointer;
}

View File

@ -11,7 +11,7 @@
--fw-radius: 10px;
--fw-font: "SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
--fw-font-size: 14px;
--fw-z: var(--z-chrome-fw);
--fw-z: 2147480000;
position: fixed;
inset: 0;

View File

@ -263,13 +263,6 @@
font-size: 0.8rem;
}
.game-lb-title {
flex-shrink: 0;
color: var(--accent);
font-size: 0.75rem;
font-style: italic;
}
.game-lb-score {
flex-shrink: 0;
color: var(--text-muted);
@ -284,7 +277,7 @@
font-size: 0.85rem;
}
@media (max-width: 1024px) {
@media (max-width: 900px) {
.game-layout {
grid-template-columns: 1fr;
}

View File

@ -144,7 +144,7 @@
justify-content: flex-end;
}
@media (max-width: 768px) {
@media (max-width: 640px) {
.gw-form {
grid-template-columns: 1fr;
}

View File

@ -99,7 +99,7 @@
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-primary);
background: rgba(var(--accent-rgb), 0.15);
background: rgba(255, 107, 53, 0.15);
}
.gist-card-star {

View File

@ -1,22 +1,5 @@
/* retoor <retoor@molodetz.nl> */
:root {
--isslop-ok-rgb: 52, 168, 83;
--isslop-fair-rgb: 124, 179, 66;
--isslop-warn-rgb: 251, 188, 4;
--isslop-high-rgb: 251, 140, 0;
--isslop-crit-rgb: 234, 67, 53;
--isslop-ok: rgb(var(--isslop-ok-rgb));
--isslop-fair: rgb(var(--isslop-fair-rgb));
--isslop-warn: rgb(var(--isslop-warn-rgb));
--isslop-high: rgb(var(--isslop-high-rgb));
--isslop-crit: rgb(var(--isslop-crit-rgb));
--isslop-ok-text: #81c995;
--isslop-warn-text: #fdd663;
--isslop-high-text: #ffb74d;
--isslop-crit-text: #f28b82;
}
.isslop-layout {
display: grid;
grid-template-columns: 260px 1fr;
@ -216,15 +199,15 @@
font-weight: 700;
font-size: 1.1rem;
flex-shrink: 0;
color: var(--white);
color: #ffffff;
background: var(--text-muted);
}
.isslop-grade-a { background: var(--isslop-ok); }
.isslop-grade-b { background: var(--isslop-fair); }
.isslop-grade-c { background: var(--isslop-warn); color: var(--bg-card); }
.isslop-grade-d { background: var(--isslop-high); }
.isslop-grade-f { background: var(--isslop-crit); }
.isslop-grade-a { background: #34a853; }
.isslop-grade-b { background: #7cb342; }
.isslop-grade-c { background: #fbbc04; color: #1a1030; }
.isslop-grade-d { background: #fb8c00; }
.isslop-grade-f { background: #ea4335; }
.isslop-live-head {
display: flex;
@ -358,11 +341,11 @@
flex-shrink: 0;
}
.isslop-gauge.isslop-grade-a { border-color: var(--isslop-ok); background: var(--bg-secondary); }
.isslop-gauge.isslop-grade-b { border-color: var(--isslop-fair); background: var(--bg-secondary); }
.isslop-gauge.isslop-grade-c { border-color: var(--isslop-warn); background: var(--bg-secondary); color: var(--text-primary); }
.isslop-gauge.isslop-grade-d { border-color: var(--isslop-high); background: var(--bg-secondary); }
.isslop-gauge.isslop-grade-f { border-color: var(--isslop-crit); background: var(--bg-secondary); }
.isslop-gauge.isslop-grade-a { border-color: #34a853; background: var(--bg-secondary); }
.isslop-gauge.isslop-grade-b { border-color: #7cb342; background: var(--bg-secondary); }
.isslop-gauge.isslop-grade-c { border-color: #fbbc04; background: var(--bg-secondary); color: var(--text-primary); }
.isslop-gauge.isslop-grade-d { border-color: #fb8c00; background: var(--bg-secondary); }
.isslop-gauge.isslop-grade-f { border-color: #ea4335; background: var(--bg-secondary); }
.isslop-gauge-grade {
font-size: 2rem;
@ -424,8 +407,8 @@
}
.isslop-chip-builder {
border-color: rgba(var(--isslop-crit-rgb), 0.6);
background: rgba(var(--isslop-crit-rgb), 0.85);
border-color: rgba(234, 67, 53, 0.6);
background: rgba(234, 67, 53, 0.85);
color: var(--text-primary);
font-weight: 600;
}
@ -478,15 +461,15 @@
}
.isslop-signal-strong {
border-color: rgba(var(--isslop-crit-rgb), 0.55);
color: var(--isslop-crit-text);
background: rgba(var(--isslop-crit-rgb), 0.12);
border-color: rgba(234, 67, 53, 0.55);
color: #f28b82;
background: rgba(234, 67, 53, 0.12);
}
.isslop-signal-medium {
border-color: rgba(var(--isslop-high-rgb), 0.5);
color: var(--isslop-high-text);
background: rgba(var(--isslop-high-rgb), 0.1);
border-color: rgba(251, 140, 0, 0.5);
color: #ffb74d;
background: rgba(251, 140, 0, 0.1);
}
.isslop-signal-weak {
@ -521,23 +504,23 @@
}
.isslop-metric-ok {
background: rgba(var(--isslop-ok-rgb), 0.16);
color: var(--isslop-ok-text);
background: rgba(52, 168, 83, 0.16);
color: #81c995;
}
.isslop-metric-warn {
background: rgba(var(--isslop-warn-rgb), 0.16);
color: var(--isslop-warn-text);
background: rgba(251, 188, 4, 0.16);
color: #fdd663;
}
.isslop-metric-high {
background: rgba(var(--isslop-high-rgb), 0.18);
color: var(--isslop-high-text);
background: rgba(251, 140, 0, 0.18);
color: #ffb74d;
}
.isslop-metric-critical {
background: rgba(var(--isslop-crit-rgb), 0.18);
color: var(--isslop-crit-text);
background: rgba(234, 67, 53, 0.18);
color: #f28b82;
}
.isslop-cat {
@ -553,27 +536,27 @@
}
.isslop-cat-human-clean {
border-color: rgba(var(--isslop-ok-rgb), 0.5);
background: rgba(var(--isslop-ok-rgb), 0.14);
color: var(--isslop-ok-text);
border-color: rgba(52, 168, 83, 0.5);
background: rgba(52, 168, 83, 0.14);
color: #81c995;
}
.isslop-cat-human-messy {
border-color: rgba(var(--isslop-warn-rgb), 0.5);
background: rgba(var(--isslop-warn-rgb), 0.12);
color: var(--isslop-warn-text);
border-color: rgba(251, 188, 4, 0.5);
background: rgba(251, 188, 4, 0.12);
color: #fdd663;
}
.isslop-cat-sophisticated-ai {
border-color: rgba(var(--isslop-high-rgb), 0.5);
background: rgba(var(--isslop-high-rgb), 0.14);
color: var(--isslop-high-text);
border-color: rgba(251, 140, 0, 0.5);
background: rgba(251, 140, 0, 0.14);
color: #ffb74d;
}
.isslop-cat-ai-slop {
border-color: rgba(var(--isslop-crit-rgb), 0.5);
background: rgba(var(--isslop-crit-rgb), 0.16);
color: var(--isslop-crit-text);
border-color: rgba(234, 67, 53, 0.5);
background: rgba(234, 67, 53, 0.16);
color: #f28b82;
}
.isslop-image-grid {
@ -700,7 +683,7 @@
color: var(--text-secondary);
}
@media (max-width: 1024px) {
@media (max-width: 900px) {
.isslop-layout {
grid-template-columns: 1fr;
}
@ -751,13 +734,13 @@
}
.isslop-source-hit .isslop-source-code {
background: rgba(var(--isslop-high-rgb), 0.08);
background: rgba(251, 140, 0, 0.08);
border-left: 3px solid var(--warning);
color: var(--text-primary);
}
.isslop-source-focus .isslop-source-code {
background: rgba(var(--accent-rgb), 0.14);
background: rgba(255, 107, 53, 0.14);
border-left: 3px solid var(--accent);
}

View File

@ -78,7 +78,7 @@
.dashboard-btn-primary:hover {
background: var(--accent-hover);
box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.25);
box-shadow: 0 4px 16px rgba(255, 107, 53, 0.25);
}
.dashboard-stats {
@ -358,7 +358,7 @@
color: var(--text-muted);
}
@media (max-width: 1024px) {
@media (max-width: 900px) {
.dashboard-main {
grid-template-columns: 1fr;
}
@ -469,7 +469,7 @@
.landing-cta:hover {
background: var(--accent-hover);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.3);
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.3);
}
.landing-features {
@ -560,7 +560,7 @@
.landing-action-primary:hover {
background: var(--accent-hover);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.3);
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.3);
}
.landing-action-secondary {

View File

@ -11,7 +11,7 @@ img[data-lightbox] {
display: none;
align-items: center;
justify-content: center;
z-index: var(--z-lightbox);
z-index: 10000;
cursor: zoom-out;
overscroll-behavior: contain;
}

View File

@ -171,7 +171,7 @@ pre.code-has-copy > .code-copy-btn {
pre.code-has-copy > .code-copy-btn:hover {
color: var(--white);
border-color: var(--accent);
border-color: var(--accent, #6366f1);
}
.embed-youtube {

View File

@ -15,7 +15,7 @@
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
z-index: var(--z-popover);
z-index: 2100;
max-height: 200px;
overflow-y: auto;
margin-bottom: 0.25rem;

View File

@ -8,37 +8,16 @@
--kb-inset: 0px;
}
dp-chat {
display: grid;
flex: 1;
min-width: 0;
min-height: 0;
width: 100%;
}
.messages-layout {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
grid-template-columns: minmax(0, 320px) 1fr;
gap: 0;
flex: 1;
min-width: 0;
min-height: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
--kb-inset: 0px;
}
dp-chat[mode="embed"] {
display: flex;
width: 100%;
height: 100%;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
--kb-inset: 0px;
}
.messages-list {
@ -50,7 +29,7 @@ dp-chat[mode="embed"] {
}
.messages-list-header {
padding: var(--space-sm) var(--space-lg);
padding: 1rem;
border-bottom: 1px solid var(--border);
position: relative;
}
@ -77,8 +56,8 @@ dp-chat[mode="embed"] {
.search-dropdown-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
gap: 0.625rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
color: var(--text-primary);
text-decoration: none;
@ -105,8 +84,8 @@ dp-chat[mode="embed"] {
.conversation-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) var(--space-lg);
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
}
@ -146,16 +125,15 @@ dp-chat[mode="embed"] {
.messages-main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.messages-main-header {
padding: var(--space-lg);
padding: 1rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: var(--space-md);
gap: 0.75rem;
flex-shrink: 0;
}
@ -176,7 +154,7 @@ dp-chat[mode="embed"] {
}
.messages-presence.online {
color: var(--success);
color: var(--success, var(--accent));
}
.messages-presence.online::before {
@ -185,7 +163,7 @@ dp-chat[mode="embed"] {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
background: var(--success, var(--accent));
margin-right: 0.3125rem;
vertical-align: middle;
}
@ -203,14 +181,78 @@ dp-chat[mode="embed"] {
display: none;
}
.message-receipt {
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.85);
margin-left: 0.375rem;
letter-spacing: -2px;
}
.message-receipt[hidden] {
display: none;
}
.message-bubble.pending {
opacity: 0.6;
}
.attachment-pending {
margin-top: 0.35rem;
font-size: 0.8rem;
font-style: italic;
color: var(--text-secondary);
}
.typing-indicator {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: var(--bg-card-hover);
border-radius: var(--radius-lg);
border-bottom-left-radius: 4px;
}
.typing-indicator[hidden] {
display: none;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: typing-bounce 1.2s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing-bounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-4px);
opacity: 1;
}
}
.messages-thread {
flex: 1;
overflow-y: auto;
padding: var(--space-lg);
padding-bottom: var(--space-sm);
padding: 1rem;
padding-bottom: 0.5rem;
display: flex;
flex-direction: column;
gap: var(--space-md);
gap: 0.75rem;
min-height: 0;
}
@ -222,11 +264,6 @@ dp-chat[mode="embed"] {
line-height: 1.5;
position: relative;
overflow-wrap: anywhere;
outline: none;
}
.message-bubble.grouped {
margin-top: calc(var(--space-xs) - var(--space-md));
}
.message-bubble.mine {
@ -243,134 +280,24 @@ dp-chat[mode="embed"] {
border-bottom-left-radius: 4px;
}
.message-bubble.pending {
opacity: 0.6;
}
.message-bubble.failed {
outline: 1px solid var(--danger);
cursor: pointer;
}
.retry-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.6875rem;
color: var(--danger);
}
.message-bubble.mine .content-copy-btn,
.message-bubble.theirs .content-copy-btn {
opacity: 0;
}
.message-bubble:hover .content-copy-btn,
.message-bubble:focus-within .content-copy-btn,
.message-bubble .content-copy-btn:focus {
opacity: 1;
}
.message-time {
font-size: 0.6875rem;
color: var(--text-muted);
margin-top: 0.25rem;
display: block;
transition: opacity 0.1s ease;
}
.message-bubble.grouped .message-time {
opacity: 0;
}
.message-bubble.grouped:hover .message-time,
.message-bubble.grouped:focus-within .message-time {
opacity: 1;
}
.message-bubble.mine .message-time {
color: rgba(255, 255, 255, 0.7);
}
.message-receipt {
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.85);
margin-left: 0.375rem;
letter-spacing: -2px;
}
.message-receipt[hidden] {
display: none;
}
.ai-adjusted-note {
margin-top: 0.25rem;
font-size: 0.6875rem;
color: var(--text-muted);
font-style: italic;
opacity: 1;
transition: opacity 0.4s ease;
}
.ai-adjusted-note.fading {
opacity: 0;
}
.attachment-pending {
margin-top: 0.35rem;
font-size: 0.8rem;
font-style: italic;
color: var(--text-secondary);
}
.typing-indicator {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
background: var(--bg-card-hover);
border-radius: var(--radius-lg);
border-bottom-left-radius: 4px;
}
.typing-indicator[hidden] {
display: none;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: chat-typing-bounce 1.2s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes chat-typing-bounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-4px);
opacity: 1;
}
}
.messages-input-area {
padding: var(--space-md) var(--space-lg);
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom));
padding: 0.75rem 1rem;
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
display: flex;
align-items: flex-end;
gap: var(--space-sm);
gap: 0.5rem;
flex-shrink: 0;
min-height: 56px;
background: var(--bg-card);
@ -384,7 +311,7 @@ dp-chat[mode="embed"] {
overflow-y: auto;
max-height: 160px;
line-height: 1.5;
padding: var(--space-sm) var(--space-md);
padding: 0.5rem 0.75rem;
font-family: inherit;
font-size: 0.875rem;
border-radius: var(--radius);
@ -394,13 +321,6 @@ dp-chat[mode="embed"] {
outline: none;
}
@supports (field-sizing: content) {
.messages-input-area textarea {
field-sizing: content;
min-height: 1lh;
}
}
.messages-input-area textarea:focus {
border-color: var(--accent);
}
@ -420,7 +340,7 @@ dp-chat[mode="embed"] {
flex: 0 0 100%;
order: -1;
margin-top: 0;
margin-bottom: var(--space-sm);
margin-bottom: 0.5rem;
}
.messages-send-btn {
@ -448,7 +368,7 @@ dp-chat[mode="embed"] {
border: 2px solid var(--white);
border-right-color: transparent;
border-radius: 50%;
animation: chat-send-spin 0.6s linear infinite;
animation: messages-send-spin 0.6s linear infinite;
}
.messages-send-btn.is-sending {
@ -468,7 +388,7 @@ dp-chat[mode="embed"] {
cursor: default;
}
@keyframes chat-send-spin {
@keyframes messages-send-spin {
to {
transform: rotate(360deg);
}
@ -481,7 +401,7 @@ dp-chat[mode="embed"] {
justify-content: center;
height: 100%;
color: var(--text-muted);
gap: var(--space-sm);
gap: 0.5rem;
}
.messages-empty p {
@ -494,28 +414,27 @@ dp-chat[mode="embed"] {
border: none;
color: var(--text-primary);
font-size: 1.25rem;
padding: var(--space-xs);
padding: 0.25rem;
cursor: pointer;
line-height: 1;
margin-right: var(--space-xs);
margin-right: 0.25rem;
}
.messages-back-btn:hover {
color: var(--accent);
}
/* ── Mobile (<= 768px) ─────────────────────────────────── */
/* ── Mobile (≤ 768px) ───────────────────────────────────── */
/* Switches layout from grid to flex column so a single visible
child (conversation list OR chat pane) always fills the viewport.
The --kb-inset is applied here so the keyboard never obscures
the input bar. */
@media (max-width: 768px) {
.page-messages {
padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
}
.messages-layout,
dp-chat[mode="embed"] {
padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
}
.messages-layout {
display: flex;
flex-direction: column;
@ -553,33 +472,17 @@ dp-chat[mode="embed"] {
.messages-input-area textarea {
font-size: 16px;
}
.message-bubble.grouped .message-time {
opacity: 1;
}
}
/* ── Small mobile (<= 480px) ────────────────────────────── */
@media (max-width: 480px) {
.message-bubble {
max-width: 82%;
}
.messages-main-header {
padding: var(--space-md);
}
}
/* ── Smallest mobile (<= 360px) ─────────────────────────── */
/* ── Small mobile (≤ 360px) ─────────────────────────────── */
@media (max-width: 360px) {
.messages-thread {
padding: var(--space-sm);
padding: 0.5rem;
}
.messages-input-area {
padding: 0.375rem var(--space-sm);
padding: 0.375rem 0.5rem;
padding-bottom: calc(0.375rem + env(safe-area-inset-bottom));
min-height: 48px;
}
@ -590,7 +493,7 @@ dp-chat[mode="embed"] {
.message-bubble {
max-width: 90%;
padding: var(--space-sm) 0.625rem;
padding: 0.5rem 0.625rem;
font-size: 0.8125rem;
}
@ -599,30 +502,11 @@ dp-chat[mode="embed"] {
}
}
/* ── Touch devices: larger tap targets (min 44x44px) ────── */
/* ── Touch devices: larger send target ──────────────────── */
@media (hover: none) and (pointer: coarse) {
.messages-send-btn {
width: 44px;
height: 44px;
}
.messages-back-btn {
min-width: 44px;
min-height: 44px;
}
.conversation-item {
min-height: 44px;
}
.message-bubble .content-copy-btn {
opacity: 1;
min-width: 44px;
min-height: 32px;
}
.message-bubble.grouped .message-time {
opacity: 1;
}
}

View File

@ -172,7 +172,7 @@
border-radius: var(--radius-lg);
}
@media (max-width: 768px) {
@media (max-width: 600px) {
.planning-ticket label {
column-gap: 0.5rem;
padding: 0.6rem 1rem;

Some files were not shown because too many files have changed in this diff Show More