diff --git a/.claude/agents/test-maintainer.md b/.claude/agents/test-maintainer.md index b551a3c4..490dfa54 100644 --- a/.claude/agents/test-maintainer.md +++ b/.claude/agents/test-maintainer.md @@ -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 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. +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. ## Obey the rules you enforce No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor ` as the first line of any file you create. diff --git a/.claude/commands/test.md b/.claude/commands/test.md index 09ae7470..623a8570 100644 --- a/.claude/commands/test.md +++ b/.claude/commands/test.md @@ -1,5 +1,5 @@ --- -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. +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. argument-hint: [unit|api|e2e|all|] 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 -v --tb=line -x` -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). +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. 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. diff --git a/CLAUDE.md b/CLAUDE.md index 417e531a..96d668d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. -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). +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.** Single test: `python -m pytest tests/e2e/feed.py::test_name -v --tb=line -x` @@ -53,10 +53,14 @@ devplace attachments prune # remove orphan attachment records/files devplace devii reset-quota # 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 # 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) @@ -281,7 +285,18 @@ 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`. -**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. +**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 `, 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. ## Feature workflow @@ -300,7 +315,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). **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. +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. Failures at any implementation step block the workflow - never skip a failed step. @@ -315,3 +330,4 @@ 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. diff --git a/Dockerfile b/Dockerfile index add00885..efc2a109 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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=10s \ +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s \ 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 '*'"] diff --git a/README.md b/README.md index 6513aff4..5d785ad2 100644 --- a/README.md +++ b/README.md @@ -129,10 +129,16 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill - **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) 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. +- **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. -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**. +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_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`). See the API reference group **Code Farm**. ## Engagement diff --git a/devplacepy/cli/game.py b/devplacepy/cli/game.py new file mode 100644 index 00000000..5d9dc436 --- /dev/null +++ b/devplacepy/cli/game.py @@ -0,0 +1,84 @@ +# retoor + +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) diff --git a/devplacepy/cli/gateway.py b/devplacepy/cli/gateway.py new file mode 100644 index 00000000..4fb22f7d --- /dev/null +++ b/devplacepy/cli/gateway.py @@ -0,0 +1,103 @@ +# retoor + +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) diff --git a/devplacepy/cli/main.py b/devplacepy/cli/main.py index 9861271c..66dbd83e 100644 --- a/devplacepy/cli/main.py +++ b/devplacepy/cli/main.py @@ -12,6 +12,8 @@ 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 def build_parser(): @@ -28,6 +30,8 @@ def build_parser(): register_backups(sub) register_containers(sub) register_migrate(sub) + register_game(sub) + register_gateway(sub) return parser diff --git a/devplacepy/database/notifications.py b/devplacepy/database/notifications.py index e7af2a00..7dd26550 100644 --- a/devplacepy/database/notifications.py +++ b/devplacepy/database/notifications.py @@ -18,6 +18,7 @@ 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)"}, ] diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index c5845de8..e674dc7a 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -358,6 +358,21 @@ 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: @@ -471,16 +486,26 @@ 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"]) - 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) + 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) _index(db, "instances", "idx_instances_project", ["project_uid"]) _index(db, "instances", "idx_instances_slug", ["slug"]) @@ -518,6 +543,10 @@ 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"]) @@ -1014,6 +1043,29 @@ def init_db(): ("legacy_speed", 0), ("legacy_plots", 0), ("legacy_defense", 0), + ("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", ""), ): @@ -1045,6 +1097,7 @@ def init_db(): ("farm_uid", ""), ("user_uid", ""), ("day", ""), + ("scope", "daily"), ("slot_index", 0), ("kind", ""), ("label", ""), @@ -1052,13 +1105,17 @@ 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 ( @@ -1077,6 +1134,72 @@ 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_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, diff --git a/devplacepy/docs_api/groups/admin.py b/devplacepy/docs_api/groups/admin.py index df707d9c..47da5621 100644 --- a/devplacepy/docs_api/groups/admin.py +++ b/devplacepy/docs_api/groups/admin.py @@ -613,6 +613,53 @@ 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", @@ -830,5 +877,37 @@ 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"}, + ), ], } diff --git a/devplacepy/docs_api/groups/game.py b/devplacepy/docs_api/groups/game.py index 08d13f9c..9d0b10e9 100644 --- a/devplacepy/docs_api/groups/game.py +++ b/devplacepy/docs_api/groups/game.py @@ -50,9 +50,10 @@ client can refresh without a second request. method="GET", path="/game/leaderboard", title="Farm leaderboard", - summary="Top farmers ranked by level, XP, and harvests.", + 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).", auth="public", - sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4}]}, + params=[field("board", "query", "string", False, "score", "Leaderboard board key.")], + sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4, "score": 1000}]}, ), endpoint( id="game-view-farm", @@ -166,9 +167,12 @@ client can refresh without a second request. method="POST", path="/game/quests/claim", title="Claim a quest", - summary="Claim a completed daily quest reward by its kind.", + 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.", auth="user", - params=[field("quest", "form", "string", True, "harvest", "Quest kind.")], + params=[ + field("quest", "form", "string", True, "harvest", "Quest kind."), + field("scope", "form", "string", False, "daily", "daily (default) or weekly."), + ], sample_response={"ok": True, "farm": {"coins": 130}}, ), endpoint( @@ -176,7 +180,7 @@ 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.", + summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.", auth="user", destructive=True, sample_response={"ok": True, "farm": {"prestige": 1}}, @@ -191,5 +195,54 @@ client can refresh without a second request. 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"}}, + ), ], } diff --git a/devplacepy/docs_api/groups/gateway.py b/devplacepy/docs_api/groups/gateway.py index 683f6d68..4cda7b8d 100644 --- a/devplacepy/docs_api/groups/gateway.py +++ b/devplacepy/docs_api/groups/gateway.py @@ -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="public", + auth="user", 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="public", + auth="user", 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="public", + auth="user", 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="public", + auth="user", interactive=False, params=[ field( diff --git a/devplacepy/docs_api/groups/profiles.py b/devplacepy/docs_api/groups/profiles.py index b7dddd24..7ebdb25c 100644 --- a/devplacepy/docs_api/groups/profiles.py +++ b/devplacepy/docs_api/groups/profiles.py @@ -320,7 +320,7 @@ four ways to sign requests. ), field( "description", - "body", + "json", "string", True, "Great work on the release!", diff --git a/devplacepy/models.py b/devplacepy/models.py index 5f00fd92..b57555ff 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -591,7 +591,25 @@ 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) diff --git a/devplacepy/routers/CLAUDE.md b/devplacepy/routers/CLAUDE.md index c3bfbecf..23ab0743 100644 --- a/devplacepy/routers/CLAUDE.md +++ b/devplacepy/routers/CLAUDE.md @@ -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`) 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` | 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/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`, `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}` | +| `/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,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` | | (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` | diff --git a/devplacepy/routers/admin/__init__.py b/devplacepy/routers/admin/__init__.py index da07ba55..59f0e1e6 100644 --- a/devplacepy/routers/admin/__init__.py +++ b/devplacepy/routers/admin/__init__.py @@ -8,6 +8,7 @@ from devplacepy.routers.admin import ( backups, bots, containers, + game, gateway_configs, issues, media, @@ -36,5 +37,6 @@ 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") diff --git a/devplacepy/routers/admin/game.py b/devplacepy/routers/admin/game.py new file mode 100644 index 00000000..c41b8978 --- /dev/null +++ b/devplacepy/routers/admin/game.py @@ -0,0 +1,97 @@ +# retoor + +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") diff --git a/devplacepy/routers/admin/gateway_configs.py b/devplacepy/routers/admin/gateway_configs.py index 6de4c256..957f0cd7 100644 --- a/devplacepy/routers/admin/gateway_configs.py +++ b/devplacepy/routers/admin/gateway_configs.py @@ -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 routing +from devplacepy.services.openai_gateway import quota, routing from devplacepy.templating import templates from devplacepy.utils import require_admin @@ -182,3 +182,92 @@ 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}) diff --git a/devplacepy/routers/game/farm.py b/devplacepy/routers/game/farm.py index d217b006..32553b41 100644 --- a/devplacepy/routers/game/farm.py +++ b/devplacepy/routers/game/farm.py @@ -88,6 +88,8 @@ 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", diff --git a/devplacepy/routers/game/index.py b/devplacepy/routers/game/index.py index 56ecda4a..17dfe6bb 100644 --- a/devplacepy/routers/game/index.py +++ b/devplacepy/routers/game/index.py @@ -6,7 +6,10 @@ from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from devplacepy.models import ( + GameCosmeticForm, + GameInfraForm, GameLegacyForm, + GameMasteryForm, GamePerkForm, GamePlantForm, GameQuestForm, @@ -49,9 +52,9 @@ async def game_state(request: Request): @router.get("/leaderboard") -async def game_leaderboard(request: Request): +async def game_leaderboard(request: Request, board: str = "score"): get_current_user(request) - entries = store.leaderboard(25) + entries = store.leaderboard_for(board, 25) return JSONResponse( GameLeaderboardOut(entries=entries).model_dump(mode="json") ) @@ -149,5 +152,55 @@ 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), reward + 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) ) diff --git a/devplacepy/routers/profile/usage.py b/devplacepy/routers/profile/usage.py index 32bdcd50..ef8f5d15 100644 --- a/devplacepy/routers/profile/usage.py +++ b/devplacepy/routers/profile/usage.py @@ -4,6 +4,7 @@ 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__) @@ -62,4 +63,22 @@ 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 diff --git a/devplacepy/schemas/__init__.py b/devplacepy/schemas/__init__.py index f710f887..2551e8ce 100644 --- a/devplacepy/schemas/__init__.py +++ b/devplacepy/schemas/__init__.py @@ -99,6 +99,7 @@ from devplacepy.schemas.backups import ( BackupStoragePathOut, ) from devplacepy.schemas.admin import ( + AdminGameOut, AdminMediaItemOut, AdminMediaOut, AdminNewsItemOut, diff --git a/devplacepy/schemas/admin.py b/devplacepy/schemas/admin.py index 1d6ba9f8..600e2cca 100644 --- a/devplacepy/schemas/admin.py +++ b/devplacepy/schemas/admin.py @@ -72,3 +72,12 @@ 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 diff --git a/devplacepy/schemas/game.py b/devplacepy/schemas/game.py index 07cbead2..8698e96c 100644 --- a/devplacepy/schemas/game.py +++ b/devplacepy/schemas/game.py @@ -15,6 +15,7 @@ class GameCropOut(_Out): min_level: int = 1 grow_seconds: int = 0 locked: bool = False + market_state: str = "normal" class GamePlotOut(_Out): @@ -62,6 +63,38 @@ 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 = "" @@ -71,6 +104,8 @@ class GameQuestOut(_Out): reward_xp: int = 0 claimed: bool = False can_claim: bool = False + scope: str = "daily" + reward_stars: int = 0 class GameFarmOut(_Out): @@ -107,6 +142,25 @@ 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): @@ -130,6 +184,9 @@ 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): diff --git a/devplacepy/services/devii/actions/catalog/game.py b/devplacepy/services/devii/actions/catalog/game.py index 542ab785..479f054b 100644 --- a/devplacepy/services/devii/actions/catalog/game.py +++ b/devplacepy/services/devii/actions/catalog/game.py @@ -3,7 +3,7 @@ from __future__ import annotations from ..spec import Action, Param -from ._shared import body, path +from ._shared import body, path, query GAME_ACTIONS: tuple[Action, ...] = ( @@ -24,10 +24,18 @@ GAME_ACTIONS: tuple[Action, ...] = ( name="game_leaderboard", method="GET", path="/game/leaderboard", - summary="List the top Code Farm players", + 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)." + ), handler="http", requires_auth=False, read_only=True, + params=(query("board", "Leaderboard board key, defaults to score."),), ), Action( name="game_view_farm", @@ -136,11 +144,16 @@ GAME_ACTIONS: tuple[Action, ...] = ( name="game_claim_quest", method="POST", path="/game/quests/claim", - summary="Claim a completed daily Code Farm quest reward", + summary="Claim a completed daily or weekly Code Farm quest/contract 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( @@ -166,4 +179,52 @@ GAME_ACTIONS: tuple[Action, ...] = ( ), ), ), + 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),), + ), ) diff --git a/devplacepy/services/devii/actions/catalog/gateway.py b/devplacepy/services/devii/actions/catalog/gateway.py index ae644b42..3a2e7194 100644 --- a/devplacepy/services/devii/actions/catalog/gateway.py +++ b/devplacepy/services/devii/actions/catalog/gateway.py @@ -117,4 +117,57 @@ 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()), + ), ) diff --git a/devplacepy/services/devii/actions/dispatcher.py b/devplacepy/services/devii/actions/dispatcher.py index bf00e782..f4a9148d 100644 --- a/devplacepy/services/devii/actions/dispatcher.py +++ b/devplacepy/services/devii/actions/dispatcher.py @@ -58,6 +58,7 @@ CONFIRM_REQUIRED = { "notification_reset", "gateway_provider_delete", "gateway_model_delete", + "gateway_quota_rule_delete", "email_account_delete", "email_delete_message", } diff --git a/devplacepy/services/devii/session/core.py b/devplacepy/services/devii/session/core.py index 40dcb3f7..c4d16200 100644 --- a/devplacepy/services/devii/session/core.py +++ b/devplacepy/services/devii/session/core.py @@ -520,8 +520,8 @@ class DeviiSession: f"{self._system_prompt}\n\n" f"{CA_IWP_SYSTEM_FRAGMENT}\n\n" f"{channel_block}\n\n" - f"{self._clock_line()}\n\n" - f"{section}" + f"{section}\n\n" + f"{self._clock_line()}" ) def _clock_line(self) -> str: @@ -538,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 %H:%M:%S')} " + f" The user's local time is {local_now.strftime('%Y-%m-%d %Hh')} " f"({tz_name}, UTC{offset_text})." ) except Exception: # noqa: BLE001 - unknown tz name: fall back to UTC only @@ -547,16 +547,21 @@ class DeviiSession: offset = timedelta(minutes=self._tz_offset_minutes) local = ( f" The user's local time is " - f"{(now + offset).strftime('%Y-%m-%d %H:%M:%S')} " + f"{(now + offset).strftime('%Y-%m-%d %Hh')} " f"(UTC{_format_offset(offset)})." ) return ( f"# CURRENT TIME\n" - f"The current UTC time is {now.strftime('%Y-%m-%dT%H:%M:%S')}Z.{local} " + f"The current UTC time is approximately {now.strftime('%Y-%m-%d %Hh')} UTC " + f"(rounded to the hour for situational awareness only).{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." + "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." ) def _stored_timezone(self) -> str: diff --git a/devplacepy/services/devii/tasks/actions.py b/devplacepy/services/devii/tasks/actions.py index 72973f4d..898734e3 100644 --- a/devplacepy/services/devii/tasks/actions.py +++ b/devplacepy/services/devii/tasks/actions.py @@ -56,6 +56,23 @@ 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", diff --git a/devplacepy/services/devii/tasks/controller.py b/devplacepy/services/devii/tasks/controller.py index 1062c4af..4c4486e0 100644 --- a/devplacepy/services/devii/tasks/controller.py +++ b/devplacepy/services/devii/tasks/controller.py @@ -69,6 +69,7 @@ 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, @@ -81,6 +82,9 @@ 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: diff --git a/devplacepy/services/game/CLAUDE.md b/devplacepy/services/game/CLAUDE.md index 79bdadb0..ab0e42cf 100644 --- a/devplacepy/services/game/CLAUDE.md +++ b/devplacepy/services/game/CLAUDE.md @@ -55,3 +55,53 @@ 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 ()`, 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 `