diff --git a/.env.example b/.env.example index adb9dcee..cfa73214 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,9 @@ JWT_SECRET=change_me_dev_only SESSION_SECRET=change_me_dev_only BETTER_AUTH_SECRET=replace_with_at_least_32_random_chars ADMIN_PASSWORD= +# BETTER_AUTH_URL: Base URL for Better Auth endpoints and OAuth callbacks +# In production, set this to the main web domain (e.g., https://corpsim.altitude-interactive.com) +# NOT the API subdomain. The nginx proxy will forward /api/auth/* to the API server. BETTER_AUTH_URL=http://localhost:4310 GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= @@ -97,8 +100,13 @@ COMPANY_SPECIALIZATION_CHANGE_COOLDOWN_HOURS=4 ######################################## # Frontend (Next.js public vars) ######################################## +NEXT_PUBLIC_APP_URL=http://localhost:4311 +# Optional explicit auth origin for Better Auth client requests. +# Leave blank to use same-origin routing. +NEXT_PUBLIC_AUTH_URL= NEXT_PUBLIC_API_URL=http://localhost:4310 NEXT_PUBLIC_APP_NAME=CorpSim +NEXT_PUBLIC_DISCORD_SERVER_URL= NEXT_PUBLIC_COMPANY_SPECIALIZATION_CHANGE_COOLDOWN_HOURS=4 # SSO provider visibility flags (defaults to false if not set) # Set to 'true' to show SSO login buttons for each provider diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ec082cb..237b22b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: workflow_run: workflows: ["Verify"] types: [completed] + branches: [main] workflow_dispatch: permissions: @@ -17,7 +18,10 @@ concurrency: jobs: release: if: | - github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/main' + ) || ( github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && diff --git a/.releases/unreleased/20260218092513-fix-github-username-in-release-notes.md b/.releases/released/v0.10.0/20260218092513-fix-github-username-in-release-notes.md similarity index 100% rename from .releases/unreleased/20260218092513-fix-github-username-in-release-notes.md rename to .releases/released/v0.10.0/20260218092513-fix-github-username-in-release-notes.md diff --git a/.releases/released/v0.10.0/20260218131646-fix-ux-data-visibility-issues.md b/.releases/released/v0.10.0/20260218131646-fix-ux-data-visibility-issues.md new file mode 100644 index 00000000..877f0f77 --- /dev/null +++ b/.releases/released/v0.10.0/20260218131646-fix-ux-data-visibility-issues.md @@ -0,0 +1,12 @@ +--- +type: minor +area: web +summary: Fix early UX and data visibility issues (forms, time display, workforce clarity, completion feedback) +--- + +- Replace prefilled form values with descriptive placeholders in market orders, production jobs, and workforce +- Add tick countdown timer showing "Next week in Xs" with tooltip explaining time progression +- Reduce health polling from 3s to 15s to minimize UI refresh indicator blinking +- Add comprehensive workforce explanations (capacity impact, allocation labels, help text) +- Add toast notifications for research and production job completions with unlocked recipe details +- Improve overall system transparency and onboarding clarity diff --git a/.releases/released/v0.10.0/20260218155700-add-building-infrastructure-phase-1.md b/.releases/released/v0.10.0/20260218155700-add-building-infrastructure-phase-1.md new file mode 100644 index 00000000..590cf386 --- /dev/null +++ b/.releases/released/v0.10.0/20260218155700-add-building-infrastructure-phase-1.md @@ -0,0 +1,14 @@ +--- +type: minor +area: sim +summary: Add building infrastructure domain layer for capital-based production system +--- + +- Add Building model with BuildingType and BuildingStatus enums to Prisma schema +- Add BUILDING_OPERATING_COST and BUILDING_ACQUISITION ledger entry types +- Implement building acquisition, operating cost application, and reactivation services +- Add production capacity tracking based on active buildings +- Buildings have weekly operating costs (7 ticks interval) +- Buildings deactivate when company cannot afford operating costs +- Create comprehensive test suite (12 passing tests) +- Prepare foundation for infrastructure-based production requirements diff --git a/.releases/released/v0.10.0/20260219091824-fix-github-oauth-redirect-url.md b/.releases/released/v0.10.0/20260219091824-fix-github-oauth-redirect-url.md new file mode 100644 index 00000000..62f24ac6 --- /dev/null +++ b/.releases/released/v0.10.0/20260219091824-fix-github-oauth-redirect-url.md @@ -0,0 +1,10 @@ +--- +type: patch +area: web +summary: Fix OAuth callback redirect URLs for GitHub, Microsoft, and Discord by configuring nginx proxy and BETTER_AUTH_URL +--- + +- Updated nginx configuration to proxy `/api/auth/*` requests from web domain to API server +- Added documentation for configuring `BETTER_AUTH_URL` to use the main web domain in production +- Fixed OAuth callback URL issues that caused "redirect_uri is not associated with this application" errors +- Ensures all OAuth providers (GitHub, Microsoft, Discord) redirect to the correct domain diff --git a/.releases/released/v0.10.0/20260219112748-add-buildings-management-ui-with-preflight-valid.md b/.releases/released/v0.10.0/20260219112748-add-buildings-management-ui-with-preflight-valid.md new file mode 100644 index 00000000..3528bfd8 --- /dev/null +++ b/.releases/released/v0.10.0/20260219112748-add-buildings-management-ui-with-preflight-valid.md @@ -0,0 +1,30 @@ +--- +type: minor +area: web,api,sim +summary: Add Buildings Management UI with preflight validation and acquisition flows (Phase 4 & 5) +--- + +**Phase 4 - Frontend Integration + Preflight + Operator Visibility:** +- Add Buildings page with region/category grouping and status display +- Add building acquisition dialog with cost preview +- Add reusable Storage Meter component with warning thresholds (80%, 95%, 100%) +- Add preflight validation endpoints (canCreateProductionJob, canPlaceBuyOrder) +- Add storage and capacity info endpoints +- Add building type definitions endpoint with balanced costs + +**Phase 5 - Acquisition Flows + Balance Pass:** +- Implement transactional building acquisition with ledger entries +- Define BuildingType dataset: Early Workshop, Factory, MegaFactory, Warehouse, HQ +- Add building reactivation flow for INACTIVE buildings +- Implement cost preview showing acquisition cost and weekly operating expenses + +**API Layer:** +- Add BuildingsController with full CRUD operations +- Add BuildingsService with ownership validation +- Add buildings API client functions and parsers + +**UI Components:** +- Add Dialog, Label, and Progress UI primitives +- Install required @radix-ui packages + +- Add Buildings Management UI with preflight validation and acquisition flows (Phase 4 & 5) diff --git a/.releases/released/v0.10.0/20260222153300-single-origin-sso-auth-routing.md b/.releases/released/v0.10.0/20260222153300-single-origin-sso-auth-routing.md new file mode 100644 index 00000000..62ee7836 --- /dev/null +++ b/.releases/released/v0.10.0/20260222153300-single-origin-sso-auth-routing.md @@ -0,0 +1,10 @@ +--- +type: patch +area: web, api +summary: Support single-origin SSO by proxying auth routes and preferring web origin for auth base URL +--- + +- Added Next.js proxy routing for `/api/auth/*` to the API upstream so auth can run on the web origin. +- Updated Better Auth base URL precedence to prefer explicit/web origins before internal API URLs. +- Hardened web API upstream resolution to avoid accidental proxy loops when public URLs point to the web origin. +- Updated deployment docs and env examples for single-domain SSO setup. diff --git a/.releases/released/v0.10.0/20260222154900-run-migrations-in-all-role-startup.md b/.releases/released/v0.10.0/20260222154900-run-migrations-in-all-role-startup.md new file mode 100644 index 00000000..73a77a5d --- /dev/null +++ b/.releases/released/v0.10.0/20260222154900-run-migrations-in-all-role-startup.md @@ -0,0 +1,8 @@ +--- +type: patch +area: ci +summary: Run Prisma migrations before launching services in APP_ROLE=all mode +--- + +- Updated `scripts/start-container.sh` so `APP_ROLE=all` applies `prisma migrate deploy` before starting API, worker, and web. +- Prevents runtime schema-readiness pauses when single-container deployments start without a dedicated migrate step. diff --git a/.releases/released/v0.10.0/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md b/.releases/released/v0.10.0/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md new file mode 100644 index 00000000..f8998a0d --- /dev/null +++ b/.releases/released/v0.10.0/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Centralize item quantity rendering and apply quantifier labels to recipe outputs +--- + +- Centralize item quantity rendering and apply quantifier labels to recipe outputs diff --git a/.releases/released/v0.10.0/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md b/.releases/released/v0.10.0/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md new file mode 100644 index 00000000..363a1e2a --- /dev/null +++ b/.releases/released/v0.10.0/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Fix market unknown item labels by using global item metadata +--- + +- Fix market unknown item labels by using global item metadata diff --git a/.releases/released/v0.10.0/20260222162747-restrict-market-views-to-active-company-tradable.md b/.releases/released/v0.10.0/20260222162747-restrict-market-views-to-active-company-tradable.md new file mode 100644 index 00000000..4f8cfc8a --- /dev/null +++ b/.releases/released/v0.10.0/20260222162747-restrict-market-views-to-active-company-tradable.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Restrict market views to active company tradable items +--- + +- Restrict market views to active company tradable items diff --git a/.releases/released/v0.10.0/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md b/.releases/released/v0.10.0/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md new file mode 100644 index 00000000..bebdcd9b --- /dev/null +++ b/.releases/released/v0.10.0/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md @@ -0,0 +1,7 @@ +--- +type: patch +area: ops +summary: Sanitize Dokploy/nginx docs to example domains and RFC 5737 IPs +--- + +- Sanitize Dokploy/nginx docs to example domains and RFC 5737 IPs diff --git a/.releases/released/v0.10.0/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md b/.releases/released/v0.10.0/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md new file mode 100644 index 00000000..e29385b4 --- /dev/null +++ b/.releases/released/v0.10.0/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md @@ -0,0 +1,7 @@ +--- +type: patch +area: ops +summary: Remove legacy API subdomain blocks from nginx sample +--- + +- Remove legacy API subdomain blocks from nginx sample diff --git a/.releases/released/v0.10.0/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md b/.releases/released/v0.10.0/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md new file mode 100644 index 00000000..ddf9eef7 --- /dev/null +++ b/.releases/released/v0.10.0/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Add ALPHA as-is disclaimer next to footer version badge +--- + +- Add ALPHA as-is disclaimer next to footer version badge diff --git a/.releases/released/v0.10.0/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md b/.releases/released/v0.10.0/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md new file mode 100644 index 00000000..3c991827 --- /dev/null +++ b/.releases/released/v0.10.0/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Show ALPHA disclaimer on version hover and overview with Discord link +--- + +- Show ALPHA disclaimer on version hover and overview with Discord link diff --git a/.releases/released/v0.10.0/20260222165119-remove-hover-helper-label-from-version-badge.md b/.releases/released/v0.10.0/20260222165119-remove-hover-helper-label-from-version-badge.md new file mode 100644 index 00000000..97c3714c --- /dev/null +++ b/.releases/released/v0.10.0/20260222165119-remove-hover-helper-label-from-version-badge.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Remove hover helper label from version badge +--- + +- Remove hover helper label from version badge diff --git a/.releases/released/v0.10.0/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md b/.releases/released/v0.10.0/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md new file mode 100644 index 00000000..95ae19e2 --- /dev/null +++ b/.releases/released/v0.10.0/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Remove maintenance overlay focus ring rectangle +--- + +- Remove maintenance overlay focus ring rectangle diff --git a/.releases/released/v0.10.0/20260222165400-hide-seeded-example-accounts-in-admin.md b/.releases/released/v0.10.0/20260222165400-hide-seeded-example-accounts-in-admin.md new file mode 100644 index 00000000..feb74fc8 --- /dev/null +++ b/.releases/released/v0.10.0/20260222165400-hide-seeded-example-accounts-in-admin.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Hide seeded example.com accounts from admin user listing +--- + +- Filtered admin dashboard user rows to exclude seeded accounts with emails ending in `@example.com`. +- Keeps production/real user account management focused and uncluttered. diff --git a/.releases/released/v0.10.0/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md b/.releases/released/v0.10.0/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md new file mode 100644 index 00000000..8de08551 --- /dev/null +++ b/.releases/released/v0.10.0/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Make ALPHA version tag clickable to Discord and share URL resolver +--- + +- Make ALPHA version tag clickable to Discord and share URL resolver diff --git a/.releases/released/v0.10.0/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md b/.releases/released/v0.10.0/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md new file mode 100644 index 00000000..a0d33c4d --- /dev/null +++ b/.releases/released/v0.10.0/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Load Discord link from runtime meta config for alpha notices +--- + +- Load Discord link from runtime meta config for alpha notices diff --git a/.releases/released/v0.10.0/20260222170600-allow-admin-developer-read-endpoints.md b/.releases/released/v0.10.0/20260222170600-allow-admin-developer-read-endpoints.md new file mode 100644 index 00000000..7f71f9e9 --- /dev/null +++ b/.releases/released/v0.10.0/20260222170600-allow-admin-developer-read-endpoints.md @@ -0,0 +1,8 @@ +--- +type: patch +area: api, web +summary: Allow admin accounts to access developer page read endpoints +--- + +- Updated player-id guard logic to permit admin `GET` access on the specific catalog endpoints used by `/developer`. +- Kept admin restrictions in place for write operations and non-allowlisted gameplay endpoints. diff --git a/.releases/released/v0.10.0/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md b/.releases/released/v0.10.0/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md new file mode 100644 index 00000000..f06b2b61 --- /dev/null +++ b/.releases/released/v0.10.0/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md @@ -0,0 +1,7 @@ +--- +type: minor +area: web +summary: Replace static onboarding tutorial with guided cross-page walkthrough +--- + +- Replace static onboarding tutorial with guided cross-page walkthrough diff --git a/.releases/released/v0.10.0/20260222171500-handle-redacted-company-cash-in-web-parsers.md b/.releases/released/v0.10.0/20260222171500-handle-redacted-company-cash-in-web-parsers.md new file mode 100644 index 00000000..062742c2 --- /dev/null +++ b/.releases/released/v0.10.0/20260222171500-handle-redacted-company-cash-in-web-parsers.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Accept optional redacted company cash fields in API parsers +--- + +- Updated web API parsers to treat `cashCents` as optional in company summary and player registry company payloads. +- Prevents admin developer page failures when backend redacts non-owned company cash values. diff --git a/.releases/released/v0.10.0/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md b/.releases/released/v0.10.0/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md new file mode 100644 index 00000000..ae78b3fb --- /dev/null +++ b/.releases/released/v0.10.0/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Clarify overview metrics and tutorial copy as world-level values +--- + +- Clarify overview metrics and tutorial copy as world-level values diff --git a/.releases/released/v0.10.0/20260222171905-start-guided-tutorial-with-active-company-snapsh.md b/.releases/released/v0.10.0/20260222171905-start-guided-tutorial-with-active-company-snapsh.md new file mode 100644 index 00000000..581cb865 --- /dev/null +++ b/.releases/released/v0.10.0/20260222171905-start-guided-tutorial-with-active-company-snapsh.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Start guided tutorial with active company snapshot before world KPIs +--- + +- Start guided tutorial with active company snapshot before world KPIs diff --git a/.releases/released/v0.10.0/20260222172500-enable-admin-developer-research-catalog.md b/.releases/released/v0.10.0/20260222172500-enable-admin-developer-research-catalog.md new file mode 100644 index 00000000..f2fe5ccd --- /dev/null +++ b/.releases/released/v0.10.0/20260222172500-enable-admin-developer-research-catalog.md @@ -0,0 +1,8 @@ +--- +type: patch +area: api, web +summary: Enable admin access to developer research catalog without player ownership +--- + +- Added admin-only research catalog read path that selects a player-owned company when no company ID is provided. +- Kept existing player ownership enforcement for non-admin research access and all research mutations. diff --git a/.releases/released/v0.10.0/20260222173000-separate-recipe-input-items-in-ui.md b/.releases/released/v0.10.0/20260222173000-separate-recipe-input-items-in-ui.md new file mode 100644 index 00000000..b4e76f68 --- /dev/null +++ b/.releases/released/v0.10.0/20260222173000-separate-recipe-input-items-in-ui.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Improve recipe input readability with explicit separators and quantity labels +--- + +- Added a reusable `ItemQuantityList` UI component that renders item inputs with clear separators and `xN` quantities. +- Updated developer and production recipe sections to use the shared list component, preventing concatenated input labels. diff --git a/.releases/released/v0.10.0/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md b/.releases/released/v0.10.0/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md new file mode 100644 index 00000000..9302e396 --- /dev/null +++ b/.releases/released/v0.10.0/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md @@ -0,0 +1,7 @@ +--- +type: patch +area: sim +summary: Refresh liquidity bot orders and add deterministic crossing to prevent zero-trade stalls +--- + +- Refresh liquidity bot orders and add deterministic crossing to prevent zero-trade stalls diff --git a/.releases/released/v0.10.0/20260222185800-fix-diagnostics-missing-items-di.md b/.releases/released/v0.10.0/20260222185800-fix-diagnostics-missing-items-di.md new file mode 100644 index 00000000..06f904ff --- /dev/null +++ b/.releases/released/v0.10.0/20260222185800-fix-diagnostics-missing-items-di.md @@ -0,0 +1,7 @@ +--- +type: patch +area: api +summary: Fix diagnostics missing-items endpoint DI so missing item logs can be created +--- + +- Harden diagnostics controller service injection with explicit @Inject assignment to avoid undefined service at runtime. diff --git a/.releases/released/v0.10.0/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md b/.releases/released/v0.10.0/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md new file mode 100644 index 00000000..3304cdb8 --- /dev/null +++ b/.releases/released/v0.10.0/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md @@ -0,0 +1,8 @@ +--- +type: patch +area: ci +summary: Fix typecheck failures in web public-links route import and bot order cancellation input +--- + +- Added a root `@/*` path mapping to `apps/web/src/*` so root `tsc` resolves web alias imports used by app route handlers. +- Removed an invalid `companyId` property passed to `cancelMarketOrderWithTx` in bot order cleanup. diff --git a/.releases/released/v0.10.0/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md b/.releases/released/v0.10.0/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md new file mode 100644 index 00000000..f0dd07a6 --- /dev/null +++ b/.releases/released/v0.10.0/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Wrap search-params dependent layout clients in Suspense to fix Next build prerendering +--- + +- Wrapped `AuthRouteGate` and `GuidedTutorialOverlay` with React `Suspense` boundaries so `useSearchParams()` does not fail static prerender checks. +- Preserved existing runtime behavior by keeping current loading/empty fallbacks. diff --git a/.releases/released/v0.10.0/20260222202100-restrict-release-workflow-to-main-branch.md b/.releases/released/v0.10.0/20260222202100-restrict-release-workflow-to-main-branch.md new file mode 100644 index 00000000..2ecdaa93 --- /dev/null +++ b/.releases/released/v0.10.0/20260222202100-restrict-release-workflow-to-main-branch.md @@ -0,0 +1,8 @@ +--- +type: patch +area: ci +summary: Restrict release workflow to main branch for both automatic and manual runs +--- + +- Added a `main` branch filter to the release workflow's `workflow_run` trigger. +- Added a job-level guard so manual dispatch runs only when dispatched from `refs/heads/main`. diff --git a/.releases/released/v0.10.0/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md b/.releases/released/v0.10.0/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md new file mode 100644 index 00000000..8f6f9d41 --- /dev/null +++ b/.releases/released/v0.10.0/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md @@ -0,0 +1,9 @@ +--- +type: patch +area: web +summary: Fix local Prisma lock races and restore Buildings/research feedback visibility +--- + +- Serialize `prisma:generate` runs with a cross-process lock to prevent Windows engine rename collisions when multiple dev scripts start at once. +- Replace deprecated `/workforce` entry points by redirecting to `/buildings` and surfacing Buildings in shared navigation. +- Move research completion toasts into a global app-shell notifier so completion feedback appears even when users are on other pages. diff --git a/.releases/released/v0.10.0/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md b/.releases/released/v0.10.0/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md new file mode 100644 index 00000000..b41e1b87 --- /dev/null +++ b/.releases/released/v0.10.0/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md @@ -0,0 +1,9 @@ +--- +type: patch +area: db +summary: Run Prisma generate without relying on dotenv-cli shell binary +--- + +- Replace `packages/db` `generate:raw` command with a Node wrapper script. +- Load `.env` directly in the wrapper and invoke `pnpm exec prisma generate`. +- Avoid Windows shell failures when the `dotenv` CLI binary is not linked in `node_modules/.bin`. diff --git a/.releases/released/v0.10.0/20260222221000-fix-buildings-definitions-response-parsing.md b/.releases/released/v0.10.0/20260222221000-fix-buildings-definitions-response-parsing.md new file mode 100644 index 00000000..03397a2f --- /dev/null +++ b/.releases/released/v0.10.0/20260222221000-fix-buildings-definitions-response-parsing.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Fix buildings definitions API response parsing in web client +--- + +- Parse `/v1/buildings/definitions` using the `definitions` field from object payloads. +- Prevent runtime error: `Invalid response field "definitions" (expected array)`. diff --git a/.releases/released/v0.10.0/20260222222800-support-legacy-buildings-definitions-payload.md b/.releases/released/v0.10.0/20260222222800-support-legacy-buildings-definitions-payload.md new file mode 100644 index 00000000..90e81772 --- /dev/null +++ b/.releases/released/v0.10.0/20260222222800-support-legacy-buildings-definitions-payload.md @@ -0,0 +1,9 @@ +--- +type: patch +area: web +summary: Accept legacy object-shaped buildings definitions payloads +--- + +- Make `getBuildingTypeDefinitions` tolerant to both array and object-map payload shapes. +- Automatically inject `buildingType` from object keys when legacy payload omits it. +- Prevent runtime parsing crashes in acquire/buildings dialogs during mixed-version dev runs. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aba99d4..05a60b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -261,3 +261,43 @@ All notable changes to CorpSim are documented in this file. - [ops] Add APP_IMAGE_BYPASS_TAG flag to force preview image builds from latest commit - [ops] Fix SSO authentication in production by wiring backend runtime env vars and frontend build-time visibility flags + +## 0.10.0 - 2026-02-22 + +### What's Changed + +- [web] Fix early UX and data visibility issues (forms, time display, workforce clarity, completion feedback) +- [sim] Add building infrastructure domain layer for capital-based production system +- [web,api,sim] Add Buildings Management UI with preflight validation and acquisition flows (Phase 4 & 5) +- [web] Replace static onboarding tutorial with guided cross-page walkthrough +- [ci] Use GitHub's built-in release notes generation +- [web] Fix OAuth callback redirect URLs for GitHub, Microsoft, and Discord by configuring nginx proxy and BETTER_AUTH_URL +- [web, api] Support single-origin SSO by proxying auth routes and preferring web origin for auth base URL +- [ci] Run Prisma migrations before launching services in APP_ROLE=all mode +- [web] Centralize item quantity rendering and apply quantifier labels to recipe outputs +- [web] Fix market unknown item labels by using global item metadata +- [web] Restrict market views to active company tradable items +- [ops] Sanitize Dokploy/nginx docs to example domains and RFC 5737 IPs +- [ops] Remove legacy API subdomain blocks from nginx sample +- [web] Add ALPHA as-is disclaimer next to footer version badge +- [web] Show ALPHA disclaimer on version hover and overview with Discord link +- [web] Remove hover helper label from version badge +- [web] Remove maintenance overlay focus ring rectangle +- [web] Hide seeded example.com accounts from admin user listing +- [web] Make ALPHA version tag clickable to Discord and share URL resolver +- [web] Load Discord link from runtime meta config for alpha notices +- [api, web] Allow admin accounts to access developer page read endpoints +- [web] Accept optional redacted company cash fields in API parsers +- [web] Clarify overview metrics and tutorial copy as world-level values +- [web] Start guided tutorial with active company snapshot before world KPIs +- [api, web] Enable admin access to developer research catalog without player ownership +- [web] Improve recipe input readability with explicit separators and quantity labels +- [sim] Refresh liquidity bot orders and add deterministic crossing to prevent zero-trade stalls +- [api] Fix diagnostics missing-items endpoint DI so missing item logs can be created +- [ci] Fix typecheck failures in web public-links route import and bot order cancellation input +- [web] Wrap search-params dependent layout clients in Suspense to fix Next build prerendering +- [ci] Restrict release workflow to main branch for both automatic and manual runs +- [web] Fix local Prisma lock races and restore Buildings/research feedback visibility +- [db] Run Prisma generate without relying on dotenv-cli shell binary +- [web] Fix buildings definitions API response parsing in web client +- [web] Accept legacy object-shaped buildings definitions payloads diff --git a/SECURITY.md b/SECURITY.md index 8bcb1bd0..fe0834ce 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ CorpSim is pre-1.0. Only the latest released version line is supported for secur | Version | Supported | | --- | --- | -| `0.9.x` and newer release lines | Yes | +| `0.10.x` and newer release lines | Yes | | Older versions | No | ## Reporting a Vulnerability diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d3d12659..c9f92069 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -2,6 +2,8 @@ import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common"; import { AuthModule } from "@thallesp/nestjs-better-auth"; import { MaintenanceModeMiddleware } from "./common/middleware/maintenance-mode.middleware"; import { SchemaReadinessMiddleware } from "./common/middleware/schema-readiness.middleware"; +import { BuildingsController } from "./buildings/buildings.controller"; +import { BuildingsService } from "./buildings/buildings.service"; import { CompaniesController } from "./companies/companies.controller"; import { CompaniesService } from "./companies/companies.service"; import { ContractsController } from "./contracts/contracts.controller"; @@ -63,6 +65,7 @@ import { auth } from "./lib/auth"; ContractsController, PlayersController, CompaniesController, + BuildingsController, MarketController, ItemsController, RegionsController, @@ -83,6 +86,7 @@ import { auth } from "./lib/auth"; FinanceService, ContractsService, CompaniesService, + BuildingsService, PlayersService, MarketService, MaintenanceService, diff --git a/apps/api/src/buildings/buildings.controller.ts b/apps/api/src/buildings/buildings.controller.ts new file mode 100644 index 00000000..0202157a --- /dev/null +++ b/apps/api/src/buildings/buildings.controller.ts @@ -0,0 +1,115 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Inject, + Post, + Query +} from "@nestjs/common"; +import { CurrentPlayerId } from "../common/decorators/current-player-id.decorator"; +import { AcquireBuildingDto } from "./dto/acquire-building.dto"; +import { ListBuildingsDto } from "./dto/list-buildings.dto"; +import { ReactivateBuildingDto } from "./dto/reactivate-building.dto"; +import { PreflightProductionJobDto } from "./dto/preflight-production-job.dto"; +import { PreflightBuyOrderDto } from "./dto/preflight-buy-order.dto"; +import { BuildingsService } from "./buildings.service"; + +@Controller("v1/buildings") +export class BuildingsController { + private readonly buildingsService: BuildingsService; + + constructor(@Inject(BuildingsService) buildingsService: BuildingsService) { + this.buildingsService = buildingsService; + } + + @Get() + async listBuildings( + @Query() query: ListBuildingsDto, + @CurrentPlayerId() playerId: string + ) { + return this.buildingsService.listBuildings(query, playerId); + } + + @Post("acquire") + async acquireBuilding( + @Body() body: AcquireBuildingDto, + @CurrentPlayerId() playerId: string + ) { + return this.buildingsService.acquireBuilding( + { + companyId: body.companyId, + regionId: body.regionId, + buildingType: body.buildingType, + name: body.name + }, + playerId + ); + } + + @Post("reactivate") + @HttpCode(HttpStatus.OK) + async reactivateBuilding( + @Body() body: ReactivateBuildingDto, + @CurrentPlayerId() playerId: string + ) { + return this.buildingsService.reactivateBuilding(body.buildingId, playerId); + } + + @Get("storage-info") + async getStorageInfo( + @Query("companyId") companyId: string, + @Query("regionId") regionId: string, + @CurrentPlayerId() playerId: string + ) { + return this.buildingsService.getRegionalStorageInfo(companyId, regionId, playerId); + } + + @Get("capacity-info") + async getCapacityInfo( + @Query("companyId") companyId: string, + @CurrentPlayerId() playerId: string + ) { + return this.buildingsService.getProductionCapacityInfo(companyId, playerId); + } + + @Post("preflight/production-job") + @HttpCode(HttpStatus.OK) + async preflightProductionJob( + @Body() body: PreflightProductionJobDto, + @CurrentPlayerId() playerId: string + ) { + return this.buildingsService.preflightProductionJob( + { + companyId: body.companyId, + recipeId: body.recipeId, + quantity: body.quantity + }, + playerId + ); + } + + @Post("preflight/buy-order") + @HttpCode(HttpStatus.OK) + async preflightBuyOrder( + @Body() body: PreflightBuyOrderDto, + @CurrentPlayerId() playerId: string + ) { + return this.buildingsService.preflightBuyOrder( + { + companyId: body.companyId, + regionId: body.regionId, + itemId: body.itemId, + quantity: body.quantity + }, + playerId + ); + } + + @Get("definitions") + async getBuildingTypeDefinitions() { + const definitions = await this.buildingsService.getBuildingTypeDefinitions(); + return { definitions }; + } +} diff --git a/apps/api/src/buildings/buildings.service.ts b/apps/api/src/buildings/buildings.service.ts new file mode 100644 index 00000000..a414cc13 --- /dev/null +++ b/apps/api/src/buildings/buildings.service.ts @@ -0,0 +1,491 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { BuildingStatus, BuildingType } from "@prisma/client"; +import type { + BuildingRecord, + RegionalStorageInfo, + ProductionCapacityInfo, + PreflightValidationResult, + ValidationIssue, + BuildingTypeDefinition +} from "@corpsim/shared"; +import { + assertCompanyOwnedByPlayer, + resolvePlayerById, + acquireBuildingWithTx, + reactivateBuildingWithTx, + getProductionCapacityForCompany, + calculateRegionalStorageCapacity, + WAREHOUSE_CAPACITY_PER_SLOT, + PRODUCTION_BUILDING_TYPES +} from "@corpsim/sim"; +import { PrismaService } from "../prisma/prisma.service"; +import { WorldService } from "../world/world.service"; + +// Building type definitions with costs (Phase 5 balance pass) +const BUILDING_DEFINITIONS: Record> = { + [BuildingType.WORKSHOP]: { + category: "PRODUCTION", + name: "Workshop", + description: "Small-scale production facility for beginners", + acquisitionCostCents: "2500000", // $25,000 + weeklyOperatingCostCents: "150000", // $1,500/week + capacitySlots: 1 + }, + [BuildingType.MINE]: { + category: "PRODUCTION", + name: "Mine", + description: "Extract raw materials from the earth", + acquisitionCostCents: "10000000", // $100,000 + weeklyOperatingCostCents: "500000", // $5,000/week + capacitySlots: 2 + }, + [BuildingType.FARM]: { + category: "PRODUCTION", + name: "Farm", + description: "Grow and harvest agricultural products", + acquisitionCostCents: "8000000", // $80,000 + weeklyOperatingCostCents: "400000", // $4,000/week + capacitySlots: 2 + }, + [BuildingType.FACTORY]: { + category: "PRODUCTION", + name: "Factory", + description: "Process materials into finished goods", + acquisitionCostCents: "25000000", // $250,000 + weeklyOperatingCostCents: "1200000", // $12,000/week + capacitySlots: 3 + }, + [BuildingType.MEGA_FACTORY]: { + category: "PRODUCTION", + name: "Mega Factory", + description: "High-capacity industrial production facility", + acquisitionCostCents: "100000000", // $1,000,000 + weeklyOperatingCostCents: "5000000", // $50,000/week + capacitySlots: 10 + }, + [BuildingType.WAREHOUSE]: { + category: "STORAGE", + name: "Warehouse", + description: "Store inventory beyond base capacity", + acquisitionCostCents: "15000000", // $150,000 + weeklyOperatingCostCents: "800000", // $8,000/week + capacitySlots: 1, + storageCapacity: WAREHOUSE_CAPACITY_PER_SLOT + }, + [BuildingType.HEADQUARTERS]: { + category: "CORPORATE", + name: "Headquarters", + description: "Corporate management and strategic operations", + acquisitionCostCents: "50000000", // $500,000 + weeklyOperatingCostCents: "2500000", // $25,000/week + capacitySlots: 1 + }, + [BuildingType.RND_CENTER]: { + category: "CORPORATE", + name: "R&D Center", + description: "Research and development facility", + acquisitionCostCents: "30000000", // $300,000 + weeklyOperatingCostCents: "1500000", // $15,000/week + capacitySlots: 1 + } +}; + +function mapBuildingToDto(building: { + id: string; + companyId: string; + regionId: string; + buildingType: BuildingType; + status: BuildingStatus; + name: string | null; + acquisitionCostCents: bigint; + weeklyOperatingCostCents: bigint; + capacitySlots: number; + tickAcquired: number; + tickConstructionCompletes: number | null; + lastOperatingCostTick: number | null; + createdAt: Date; + updatedAt: Date; + region: { + id: string; + code: string; + name: string; + }; +}): BuildingRecord { + return { + id: building.id, + companyId: building.companyId, + regionId: building.regionId, + buildingType: building.buildingType as BuildingType, + status: building.status as BuildingStatus, + name: building.name, + acquisitionCostCents: building.acquisitionCostCents.toString(), + weeklyOperatingCostCents: building.weeklyOperatingCostCents.toString(), + capacitySlots: building.capacitySlots, + tickAcquired: building.tickAcquired, + tickConstructionCompletes: building.tickConstructionCompletes, + lastOperatingCostTick: building.lastOperatingCostTick, + createdAt: building.createdAt.toISOString(), + updatedAt: building.updatedAt.toISOString(), + region: { + id: building.region.id, + code: building.region.code, + name: building.region.name + } + }; +} + +@Injectable() +export class BuildingsService { + private readonly prisma: PrismaService; + private readonly worldService: WorldService; + + constructor( + @Inject(PrismaService) prisma: PrismaService, + @Inject(WorldService) worldService: WorldService + ) { + this.prisma = prisma; + this.worldService = worldService; + } + + async listBuildings( + filters: { companyId: string; regionId?: string; status?: BuildingStatus }, + playerId: string + ): Promise { + const player = await resolvePlayerById(this.prisma, playerId); + await assertCompanyOwnedByPlayer(this.prisma, player.id, filters.companyId); + + const buildings = await this.prisma.building.findMany({ + where: { + companyId: filters.companyId, + ...(filters.regionId && { regionId: filters.regionId }), + ...(filters.status && { status: filters.status }) + }, + include: { + region: { + select: { + id: true, + code: true, + name: true + } + } + }, + orderBy: [ + { regionId: "asc" }, + { buildingType: "asc" }, + { createdAt: "asc" } + ] + }); + + return buildings.map(mapBuildingToDto); + } + + async acquireBuilding( + input: { companyId: string; regionId: string; buildingType: BuildingType; name?: string }, + playerId: string + ): Promise { + const player = await resolvePlayerById(this.prisma, playerId); + await assertCompanyOwnedByPlayer(this.prisma, player.id, input.companyId); + + const definition = BUILDING_DEFINITIONS[input.buildingType]; + if (!definition) { + throw new Error(`Unknown building type: ${input.buildingType}`); + } + + const worldState = await this.worldService.getTickState(); + const currentTick = worldState.currentTick; + + const building = await this.prisma.$transaction(async (tx) => { + return acquireBuildingWithTx(tx, { + companyId: input.companyId, + regionId: input.regionId, + buildingType: input.buildingType, + name: input.name, + acquisitionCostCents: BigInt(definition.acquisitionCostCents), + weeklyOperatingCostCents: BigInt(definition.weeklyOperatingCostCents), + capacitySlots: definition.capacitySlots, + tick: currentTick + }); + }); + + const buildingWithRegion = await this.prisma.building.findUniqueOrThrow({ + where: { id: building.id }, + include: { + region: { + select: { + id: true, + code: true, + name: true + } + } + } + }); + + return mapBuildingToDto(buildingWithRegion); + } + + async reactivateBuilding( + buildingId: string, + playerId: string + ): Promise { + // Verify ownership + const building = await this.prisma.building.findUniqueOrThrow({ + where: { id: buildingId }, + select: { companyId: true } + }); + + const player = await resolvePlayerById(this.prisma, playerId); + await assertCompanyOwnedByPlayer(this.prisma, player.id, building.companyId); + + const worldState = await this.worldService.getTickState(); + const currentTick = worldState.currentTick; + + const reactivated = await this.prisma.$transaction(async (tx) => { + return reactivateBuildingWithTx(tx, { + buildingId, + tick: currentTick + }); + }); + + const buildingWithRegion = await this.prisma.building.findUniqueOrThrow({ + where: { id: reactivated.id }, + include: { + region: { + select: { + id: true, + code: true, + name: true + } + } + } + }); + + return mapBuildingToDto(buildingWithRegion); + } + + async getRegionalStorageInfo( + companyId: string, + regionId: string, + playerId: string + ): Promise { + const player = await resolvePlayerById(this.prisma, playerId); + await assertCompanyOwnedByPlayer(this.prisma, player.id, companyId); + + const [currentInventory, warehouseCount] = await Promise.all([ + this.prisma.inventory.aggregate({ + where: { companyId, regionId }, + _sum: { quantity: true } + }), + this.prisma.building.count({ + where: { + companyId, + regionId, + buildingType: BuildingType.WAREHOUSE, + status: BuildingStatus.ACTIVE + } + }) + ]); + + const maxCapacity = calculateRegionalStorageCapacity(warehouseCount); + const currentUsage = currentInventory._sum.quantity || 0; + const usagePercentage = maxCapacity > 0 ? (currentUsage / maxCapacity) * 100 : 0; + + return { + companyId, + regionId, + currentUsage, + maxCapacity, + usagePercentage, + warehouseCount + }; + } + + async getProductionCapacityInfo( + companyId: string, + playerId: string + ): Promise { + const player = await resolvePlayerById(this.prisma, playerId); + await assertCompanyOwnedByPlayer(this.prisma, player.id, companyId); + + const capacityInfo = await getProductionCapacityForCompany(this.prisma, companyId); + + return { + companyId, + totalCapacity: capacityInfo.totalCapacity, + usedCapacity: capacityInfo.usedCapacity, + availableCapacity: capacityInfo.totalCapacity - capacityInfo.usedCapacity, + usagePercentage: + capacityInfo.totalCapacity > 0 + ? (capacityInfo.usedCapacity / capacityInfo.totalCapacity) * 100 + : 0 + }; + } + + async preflightProductionJob( + input: { companyId: string; recipeId: string; quantity: number }, + playerId: string + ): Promise { + const player = await resolvePlayerById(this.prisma, playerId); + await assertCompanyOwnedByPlayer(this.prisma, player.id, input.companyId); + + const issues: ValidationIssue[] = []; + + try { + // Check for active production buildings + const activeBuildingCount = await this.prisma.building.count({ + where: { + companyId: input.companyId, + buildingType: { in: PRODUCTION_BUILDING_TYPES }, + status: BuildingStatus.ACTIVE + } + }); + + if (activeBuildingCount === 0) { + issues.push({ + code: "NO_ACTIVE_BUILDING", + message: "No active production buildings available", + severity: "ERROR" + }); + } + + // Check production capacity + const capacityInfo = await getProductionCapacityForCompany( + this.prisma, + input.companyId + ); + + if (capacityInfo.usedCapacity >= capacityInfo.totalCapacity) { + issues.push({ + code: "BUILDING_CAPACITY_FULL", + message: `Production capacity full: ${capacityInfo.usedCapacity}/${capacityInfo.totalCapacity} slots used`, + severity: "ERROR" + }); + } + + // Check storage capacity for output + const recipe = await this.prisma.recipe.findUnique({ + where: { id: input.recipeId }, + include: { outputItem: true } + }); + + if (recipe) { + const company = await this.prisma.company.findUnique({ + where: { id: input.companyId }, + select: { regionId: true } + }); + + if (company) { + const outputQuantity = recipe.outputQuantity * input.quantity; + const currentInventory = await this.prisma.inventory.aggregate({ + where: { companyId: input.companyId, regionId: company.regionId }, + _sum: { quantity: true } + }); + + const warehouseCount = await this.prisma.building.count({ + where: { + companyId: input.companyId, + regionId: company.regionId, + buildingType: BuildingType.WAREHOUSE, + status: BuildingStatus.ACTIVE + } + }); + + const capacity = calculateRegionalStorageCapacity(warehouseCount); + const currentTotal = currentInventory._sum.quantity || 0; + + if (currentTotal + outputQuantity > capacity) { + issues.push({ + code: "INSUFFICIENT_STORAGE", + message: `Insufficient storage: need ${currentTotal + outputQuantity}, capacity ${capacity}`, + severity: "ERROR" + }); + } else if (currentTotal + outputQuantity > capacity * 0.8) { + issues.push({ + code: "STORAGE_WARNING", + message: `Storage will be ${Math.round(((currentTotal + outputQuantity) / capacity) * 100)}% full after production`, + severity: "WARNING" + }); + } + } + } + } catch (error) { + issues.push({ + code: "VALIDATION_ERROR", + message: error instanceof Error ? error.message : "Unknown validation error", + severity: "ERROR" + }); + } + + return { + valid: !issues.some((issue) => issue.severity === "ERROR"), + issues + }; + } + + async preflightBuyOrder( + input: { companyId: string; regionId: string; itemId: string; quantity: number }, + playerId: string + ): Promise { + const player = await resolvePlayerById(this.prisma, playerId); + await assertCompanyOwnedByPlayer(this.prisma, player.id, input.companyId); + + const issues: ValidationIssue[] = []; + + try { + // Check storage capacity + const currentInventory = await this.prisma.inventory.aggregate({ + where: { companyId: input.companyId, regionId: input.regionId }, + _sum: { quantity: true } + }); + + const warehouseCount = await this.prisma.building.count({ + where: { + companyId: input.companyId, + regionId: input.regionId, + buildingType: BuildingType.WAREHOUSE, + status: BuildingStatus.ACTIVE + } + }); + + const capacity = calculateRegionalStorageCapacity(warehouseCount); + const currentTotal = currentInventory._sum.quantity || 0; + + if (currentTotal + input.quantity > capacity) { + issues.push({ + code: "INSUFFICIENT_STORAGE", + message: `Insufficient storage: need ${currentTotal + input.quantity}, capacity ${capacity}`, + severity: "ERROR" + }); + } else if (currentTotal + input.quantity > capacity * 0.95) { + issues.push({ + code: "STORAGE_CRITICAL", + message: `Storage will be ${Math.round(((currentTotal + input.quantity) / capacity) * 100)}% full after purchase`, + severity: "WARNING" + }); + } else if (currentTotal + input.quantity > capacity * 0.8) { + issues.push({ + code: "STORAGE_WARNING", + message: `Storage will be ${Math.round(((currentTotal + input.quantity) / capacity) * 100)}% full after purchase`, + severity: "WARNING" + }); + } + } catch (error) { + issues.push({ + code: "VALIDATION_ERROR", + message: error instanceof Error ? error.message : "Unknown validation error", + severity: "ERROR" + }); + } + + return { + valid: !issues.some((issue) => issue.severity === "ERROR"), + issues + }; + } + + async getBuildingTypeDefinitions(): Promise { + return Object.entries(BUILDING_DEFINITIONS).map(([buildingType, definition]) => ({ + buildingType: buildingType as BuildingType, + ...definition + })); + } +} diff --git a/apps/api/src/buildings/dto/acquire-building.dto.ts b/apps/api/src/buildings/dto/acquire-building.dto.ts new file mode 100644 index 00000000..a93b8088 --- /dev/null +++ b/apps/api/src/buildings/dto/acquire-building.dto.ts @@ -0,0 +1,19 @@ +import { IsEnum, IsOptional, IsString, MinLength } from "class-validator"; +import { BuildingType } from "@prisma/client"; + +export class AcquireBuildingDto { + @IsString() + @MinLength(1) + companyId!: string; + + @IsString() + @MinLength(1) + regionId!: string; + + @IsEnum(BuildingType) + buildingType!: BuildingType; + + @IsOptional() + @IsString() + name?: string; +} diff --git a/apps/api/src/buildings/dto/list-buildings.dto.ts b/apps/api/src/buildings/dto/list-buildings.dto.ts new file mode 100644 index 00000000..4ed0edd9 --- /dev/null +++ b/apps/api/src/buildings/dto/list-buildings.dto.ts @@ -0,0 +1,16 @@ +import { IsEnum, IsOptional, IsString, MinLength } from "class-validator"; +import { BuildingStatus } from "@prisma/client"; + +export class ListBuildingsDto { + @IsString() + @MinLength(1) + companyId!: string; + + @IsOptional() + @IsString() + regionId?: string; + + @IsOptional() + @IsEnum(BuildingStatus) + status?: BuildingStatus; +} diff --git a/apps/api/src/buildings/dto/preflight-buy-order.dto.ts b/apps/api/src/buildings/dto/preflight-buy-order.dto.ts new file mode 100644 index 00000000..d2972c50 --- /dev/null +++ b/apps/api/src/buildings/dto/preflight-buy-order.dto.ts @@ -0,0 +1,20 @@ +import { IsInt, IsNumber, IsString, Min, MinLength } from "class-validator"; + +export class PreflightBuyOrderDto { + @IsString() + @MinLength(1) + companyId!: string; + + @IsString() + @MinLength(1) + regionId!: string; + + @IsString() + @MinLength(1) + itemId!: string; + + @IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 0 }) + @IsInt() + @Min(1) + quantity!: number; +} diff --git a/apps/api/src/buildings/dto/preflight-production-job.dto.ts b/apps/api/src/buildings/dto/preflight-production-job.dto.ts new file mode 100644 index 00000000..fc3af024 --- /dev/null +++ b/apps/api/src/buildings/dto/preflight-production-job.dto.ts @@ -0,0 +1,16 @@ +import { IsInt, IsNumber, IsString, Min, MinLength } from "class-validator"; + +export class PreflightProductionJobDto { + @IsString() + @MinLength(1) + companyId!: string; + + @IsString() + @MinLength(1) + recipeId!: string; + + @IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 0 }) + @IsInt() + @Min(1) + quantity!: number; +} diff --git a/apps/api/src/buildings/dto/reactivate-building.dto.ts b/apps/api/src/buildings/dto/reactivate-building.dto.ts new file mode 100644 index 00000000..803df60f --- /dev/null +++ b/apps/api/src/buildings/dto/reactivate-building.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from "class-validator"; + +export class ReactivateBuildingDto { + @IsString() + @MinLength(1) + buildingId!: string; +} diff --git a/apps/api/src/common/decorators/current-player-id.decorator.ts b/apps/api/src/common/decorators/current-player-id.decorator.ts index e59f162e..a6b1cb2b 100644 --- a/apps/api/src/common/decorators/current-player-id.decorator.ts +++ b/apps/api/src/common/decorators/current-player-id.decorator.ts @@ -8,6 +8,10 @@ import type { UserSession } from "@thallesp/nestjs-better-auth"; interface RequestWithSession { session?: UserSession | null; + method?: string; + url?: string; + originalUrl?: string; + path?: string; } function isAdminRole(role: string | string[] | null | undefined): boolean { @@ -20,6 +24,32 @@ function isAdminRole(role: string | string[] | null | undefined): boolean { .some((entry) => entry === "admin"); } +const ADMIN_ALLOWED_READ_PATH_PREFIXES = [ + "/v1/companies", + "/v1/items", + "/v1/production/recipes", + "/v1/research" +] as const; + +function resolveRequestPath(request: RequestWithSession): string { + const rawPath = request.originalUrl ?? request.path ?? request.url ?? ""; + const pathWithoutQuery = rawPath.split("?", 1)[0] ?? ""; + const normalized = pathWithoutQuery.trim(); + return normalized.length > 0 ? normalized : "/"; +} + +export function canAdminAccessPlayerGameplayEndpoint(request: RequestWithSession): boolean { + const method = request.method?.trim().toUpperCase(); + if (method !== "GET") { + return false; + } + + const path = resolveRequestPath(request); + return ADMIN_ALLOWED_READ_PATH_PREFIXES.some( + (prefix) => path === prefix || path.startsWith(`${prefix}/`) + ); +} + function resolveTestFallbackPlayerId(): string | null { if (process.env.NODE_ENV !== "test") { return null; @@ -43,6 +73,9 @@ export const CurrentPlayerId = createParamDecorator( } if (isAdminRole(request.session?.user?.role)) { + if (canAdminAccessPlayerGameplayEndpoint(request)) { + return playerId; + } throw new ForbiddenException("Admin accounts cannot access player gameplay endpoints."); } return playerId; diff --git a/apps/api/src/diagnostics/diagnostics.controller.ts b/apps/api/src/diagnostics/diagnostics.controller.ts index e720f64f..2c4ec945 100644 --- a/apps/api/src/diagnostics/diagnostics.controller.ts +++ b/apps/api/src/diagnostics/diagnostics.controller.ts @@ -1,5 +1,6 @@ import { BadRequestException, + Inject, Controller, ForbiddenException, Get, @@ -89,7 +90,11 @@ function isStaffRole(role: string | string[] | null | undefined): boolean { @Controller("diagnostics") export class DiagnosticsController { - constructor(private readonly diagnosticsService: DiagnosticsService) {} + private readonly diagnosticsService: DiagnosticsService; + + constructor(@Inject(DiagnosticsService) diagnosticsService: DiagnosticsService) { + this.diagnosticsService = diagnosticsService; + } private assertStaffSession(request: RequestWithSession): void { const session = request.session; diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 06fc5bcb..657ab634 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -476,6 +476,7 @@ async function ensurePlayerExistsForAuthUser(user: { function resolveTrustedOrigins(): string[] { const sources = [ + process.env.BETTER_AUTH_URL, process.env.CORS_ORIGIN, process.env.APP_URL, process.env.WEB_URL, @@ -497,14 +498,24 @@ function resolveTrustedOrigins(): string[] { return Array.from(resolved); } +function trimTrailingSlash(value: string): string { + return value.endsWith("/") ? value.slice(0, -1) : value; +} + function resolveAuthBaseUrl(): string { - const explicit = - process.env.BETTER_AUTH_URL?.trim() || - process.env.API_URL?.trim() || - process.env.APP_URL?.trim(); + const sources = [ + process.env.BETTER_AUTH_URL, + process.env.APP_URL, + process.env.WEB_URL, + process.env.NEXT_PUBLIC_APP_URL, + process.env.API_URL + ]; - if (explicit) { - return explicit; + for (const source of sources) { + const explicit = source?.trim(); + if (explicit) { + return trimTrailingSlash(explicit); + } } const apiPort = process.env.API_PORT?.trim() || process.env.PORT?.trim() || "4310"; diff --git a/apps/api/src/research/research.controller.ts b/apps/api/src/research/research.controller.ts index ce87b435..2f1bd171 100644 --- a/apps/api/src/research/research.controller.ts +++ b/apps/api/src/research/research.controller.ts @@ -1,10 +1,25 @@ -import { Body, Controller, Get, Inject, Param, Post, Query } from "@nestjs/common"; +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from "@nestjs/common"; +import type { UserSession } from "@thallesp/nestjs-better-auth"; import { CurrentPlayerId } from "../common/decorators/current-player-id.decorator"; import { ListResearchDto } from "./dto/list-research.dto"; import { MutateResearchDto } from "./dto/mutate-research.dto"; import { ResearchNodeParamDto } from "./dto/research-node-param.dto"; import { ResearchService } from "./research.service"; +interface RequestWithSession { + session?: UserSession | null; +} + +function isAdminRole(role: string | string[] | null | undefined): boolean { + if (!role) { + return false; + } + const roleValues = Array.isArray(role) ? role : role.split(","); + return roleValues + .map((entry) => entry.trim().toLowerCase()) + .some((entry) => entry === "admin"); +} + @Controller("v1/research") export class ResearchController { private readonly researchService: ResearchService; @@ -16,8 +31,13 @@ export class ResearchController { @Get() async list( @Query() query: ListResearchDto, + @Req() request: RequestWithSession, @CurrentPlayerId() playerId: string ) { + if (isAdminRole(request.session?.user?.role)) { + return this.researchService.listResearchForAdminCatalog(query.companyId); + } + return this.researchService.listResearch(query.companyId, playerId); } diff --git a/apps/api/src/research/research.service.ts b/apps/api/src/research/research.service.ts index 4e74ee32..6c932c4c 100644 --- a/apps/api/src/research/research.service.ts +++ b/apps/api/src/research/research.service.ts @@ -54,6 +54,31 @@ export class ResearchService { this.prisma = prisma; } + private async resolveAdminCatalogCompanyId(companyId?: string): Promise { + if (companyId) { + return companyId; + } + + const firstPlayerCompany = await this.prisma.company.findFirst({ + where: { + isPlayer: true, + ownerPlayerId: { not: null } + }, + orderBy: { + createdAt: "asc" + }, + select: { + id: true + } + }); + + if (!firstPlayerCompany) { + throw new DomainInvariantError("no player-owned companies available for research catalog"); + } + + return firstPlayerCompany.id; + } + private async resolveOwnedCompanyId(playerId: string, companyId?: string): Promise { if (companyId) { await assertCompanyOwnedByPlayer(this.prisma, playerId, companyId); @@ -84,6 +109,20 @@ export class ResearchService { }; } + async listResearchForAdminCatalog( + companyId?: string + ): Promise<{ companyId: string; nodes: ResearchNode[] }> { + const resolvedCompanyId = await this.resolveAdminCatalogCompanyId(companyId); + const nodes = await listResearchForCompany(this.prisma, { + companyId: resolvedCompanyId + }); + + return { + companyId: resolvedCompanyId, + nodes: nodes.map(mapNodeToDto) + }; + } + async startNode( nodeId: string, companyId: string | undefined, diff --git a/apps/api/test/current-player-id.decorator.test.ts b/apps/api/test/current-player-id.decorator.test.ts new file mode 100644 index 00000000..70be25ed --- /dev/null +++ b/apps/api/test/current-player-id.decorator.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { canAdminAccessPlayerGameplayEndpoint } from "../src/common/decorators/current-player-id.decorator"; + +describe("canAdminAccessPlayerGameplayEndpoint", () => { + it("allows admin GET requests to developer catalog read endpoints", () => { + expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/companies" })).toBe(true); + expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/items" })).toBe(true); + expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/production/recipes" })).toBe( + true + ); + expect( + canAdminAccessPlayerGameplayEndpoint({ + method: "GET", + url: "/v1/research?companyId=company_seed" + }) + ).toBe(true); + }); + + it("denies admin access for non-allowlisted paths", () => { + expect( + canAdminAccessPlayerGameplayEndpoint({ + method: "GET", + url: "/v1/market/orders" + }) + ).toBe(false); + }); + + it("denies admin access for write methods even on allowlisted paths", () => { + expect( + canAdminAccessPlayerGameplayEndpoint({ + method: "POST", + url: "/v1/production/recipes" + }) + ).toBe(false); + }); +}); diff --git a/apps/api/test/research-admin-catalog.integration.test.ts b/apps/api/test/research-admin-catalog.integration.test.ts new file mode 100644 index 00000000..7a69abdf --- /dev/null +++ b/apps/api/test/research-admin-catalog.integration.test.ts @@ -0,0 +1,64 @@ +import "reflect-metadata"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { seedWorld } from "@corpsim/db"; +import { HttpErrorFilter } from "../src/common/filters/http-error.filter"; +import { AppModule } from "../src/app.module"; +import { PrismaService } from "../src/prisma/prisma.service"; +import { ResearchService } from "../src/research/research.service"; + +describe("research admin catalog", () => { + let app: INestApplication; + let prisma: PrismaService; + let researchService: ResearchService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule] + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + forbidUnknownValues: true, + transform: false, + stopAtFirstError: true + }) + ); + app.useGlobalFilters(new HttpErrorFilter()); + await app.init(); + + prisma = app.get(PrismaService); + researchService = app.get(ResearchService); + }); + + beforeEach(async () => { + await seedWorld(prisma, { reset: true }); + }); + + afterAll(async () => { + await app.close(); + }); + + it("returns a research catalog for admins without requiring player ownership", async () => { + const result = await researchService.listResearchForAdminCatalog(); + + expect(typeof result.companyId).toBe("string"); + expect(result.companyId.length).toBeGreaterThan(0); + expect(result.nodes.length).toBeGreaterThan(0); + + const company = await prisma.company.findUniqueOrThrow({ + where: { id: result.companyId }, + select: { + isPlayer: true, + ownerPlayerId: true + } + }); + + expect(company.isPlayer).toBe(true); + expect(company.ownerPlayerId).not.toBeNull(); + }); +}); diff --git a/apps/web/app/api/auth/[[...path]]/route.ts b/apps/web/app/api/auth/[[...path]]/route.ts new file mode 100644 index 00000000..56acf40f --- /dev/null +++ b/apps/web/app/api/auth/[[...path]]/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from "next/server"; + +type RouteContext = { + params: Promise<{ + path?: string[]; + }>; +}; + +const REQUEST_BLOCKED_HEADERS = new Set([ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" +]); + +const RESPONSE_BLOCKED_HEADERS = new Set([ + "connection", + "content-encoding", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" +]); + +function sanitizeHeaders(source: Headers, blocked: Set): Headers { + const target = new Headers(); + source.forEach((value, key) => { + if (!blocked.has(key.toLowerCase())) { + target.append(key, value); + } + }); + return target; +} + +function resolveApiUpstreamBaseUrl(): string { + const explicit = process.env.API_URL?.trim() || process.env.API_INTERNAL_URL?.trim(); + + if (explicit) { + return explicit.endsWith("/") ? explicit.slice(0, -1) : explicit; + } + + const port = process.env.API_PORT?.trim() || "4310"; + return `http://127.0.0.1:${port}`; +} + +async function proxyToApi(request: NextRequest, context: RouteContext): Promise { + const { path } = await context.params; + const suffix = path && path.length > 0 ? `/${path.join("/")}` : ""; + const upstreamUrl = new URL(`${resolveApiUpstreamBaseUrl()}/api/auth${suffix}`); + upstreamUrl.search = request.nextUrl.search; + + const headers = sanitizeHeaders(request.headers, REQUEST_BLOCKED_HEADERS); + const method = request.method.toUpperCase(); + + let body: ArrayBuffer | undefined; + if (method !== "GET" && method !== "HEAD") { + const buffer = await request.arrayBuffer(); + body = buffer.byteLength > 0 ? buffer : undefined; + } + + try { + const upstreamResponse = await fetch(upstreamUrl, { + method, + headers, + body, + cache: "no-store", + redirect: "manual" + }); + + const responseHeaders = sanitizeHeaders(upstreamResponse.headers, RESPONSE_BLOCKED_HEADERS); + return new NextResponse(upstreamResponse.body, { + status: upstreamResponse.status, + headers: responseHeaders + }); + } catch { + return NextResponse.json({ message: "Auth upstream is unreachable." }, { status: 502 }); + } +} + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function POST(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function PUT(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function PATCH(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function DELETE(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function OPTIONS(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function HEAD(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} diff --git a/apps/web/app/buildings/page.tsx b/apps/web/app/buildings/page.tsx new file mode 100644 index 00000000..ce363a60 --- /dev/null +++ b/apps/web/app/buildings/page.tsx @@ -0,0 +1,5 @@ +import { BuildingsPage } from "@/components/buildings/buildings-page"; + +export default function BuildingsRoute() { + return ; +} diff --git a/apps/web/app/meta/public-links/route.ts b/apps/web/app/meta/public-links/route.ts new file mode 100644 index 00000000..f0fddd25 --- /dev/null +++ b/apps/web/app/meta/public-links/route.ts @@ -0,0 +1,53 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { config } from "dotenv"; +import { NextResponse } from "next/server"; +import { getDiscordServerUrl } from "@/lib/public-links"; + +let loadedEnv = false; + +function ensureEnvironmentLoaded(): void { + if (loadedEnv) { + return; + } + + if (process.env.NEXT_PUBLIC_DISCORD_SERVER_URL) { + loadedEnv = true; + return; + } + + const candidates = [ + process.env.DOTENV_PATH, + resolve(process.cwd(), ".env"), + resolve(process.cwd(), "../.env"), + resolve(process.cwd(), "../../.env") + ].filter((entry): entry is string => Boolean(entry)); + + for (const path of candidates) { + if (!existsSync(path)) { + continue; + } + config({ path, override: false }); + break; + } + + loadedEnv = true; +} + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + ensureEnvironmentLoaded(); + + return NextResponse.json( + { + discordServerUrl: getDiscordServerUrl() + }, + { + headers: { + "Cache-Control": "no-store" + } + } + ); +} diff --git a/apps/web/app/tutorial/page.tsx b/apps/web/app/tutorial/page.tsx index ad57768a..d2420d1a 100644 --- a/apps/web/app/tutorial/page.tsx +++ b/apps/web/app/tutorial/page.tsx @@ -1,57 +1,15 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { AuthPageShell } from "@/components/auth/auth-page-shell"; import { completeOnboardingTutorial, getOnboardingStatus } from "@/lib/api"; import { getDocumentationUrl } from "@/lib/ui-copy"; +import { GUIDED_TUTORIAL_STEPS } from "@/components/tutorial/guided-tutorial-steps"; -interface TutorialStep { - title: string; - summary: string; - bullets: string[]; -} - -const TUTORIAL_STEPS: TutorialStep[] = [ - { - title: "Welcome to CorpSim", - summary: "You run a company in a living market. Every choice affects your growth.", - bullets: [ - "You produce goods, trade them, and grow your company over time.", - "Markets move as companies buy and sell, so timing matters.", - "Plan ahead: cash, stock, and production speed all work together." - ] - }, - { - title: "How the Economy Works", - summary: "Buy low, sell smart, and keep enough stock to avoid downtime.", - bullets: [ - "Production turns raw materials into higher-value products.", - "If demand is high and supply is low, prices usually rise.", - "Keep reserve cash so you can react quickly to opportunities." - ] - }, - { - title: "Core Features", - summary: "These pages are your daily tools for running the company.", - bullets: [ - "Production: start jobs and keep lines running.", - "Market and Contracts: buy inputs, sell outputs, and secure deals.", - "Finance, Research, and Logistics: track cash, unlock upgrades, and move goods." - ] - }, - { - title: "Documentation", - summary: "Use the docs anytime for walkthroughs and page-by-page help.", - bullets: [ - "Open the docs from the top bar or sidebar while playing.", - "Follow module guides to learn efficient production loops.", - "Use references when you need exact steps for a feature." - ] - } -]; +const GUIDED_TUTORIAL_TOTAL_STEPS = GUIDED_TUTORIAL_STEPS.length; function readErrorMessage(error: unknown): string { if (error && typeof error === "object" && "message" in error && typeof error.message === "string") { @@ -65,7 +23,6 @@ export default function TutorialPage() { const [isLoading, setLoading] = useState(true); const [isSubmitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const [stepIndex, setStepIndex] = useState(0); useEffect(() => { let active = true; @@ -104,15 +61,7 @@ export default function TutorialPage() { }; }, [router]); - const step = useMemo(() => TUTORIAL_STEPS[stepIndex], [stepIndex]); - const isLastStep = stepIndex >= TUTORIAL_STEPS.length - 1; - - async function handleContinue() { - if (!isLastStep) { - setStepIndex((current) => Math.min(current + 1, TUTORIAL_STEPS.length - 1)); - return; - } - + async function handleSkipTutorial() { if (isSubmitting) { return; } @@ -130,18 +79,22 @@ export default function TutorialPage() { } } + function handleStartTour() { + router.replace("/overview?tutorial=1&tutorialStep=0"); + } + if (isLoading) { return ( - -

Preparing your first steps in CorpSim.

+ +

Preparing your first guided steps.

); } return (
-

- Step {stepIndex + 1} of {TUTORIAL_STEPS.length} -

-

{step.title}

-

{step.summary}

+

What to expect

+

+ You will be guided through Overview, Market, Production, and Inventory with focused + highlights on the exact sections that matter first. +

    - {step.bullets.map((bullet) => ( -
  • - {bullet}
  • - ))} +
  • - {GUIDED_TUTORIAL_TOTAL_STEPS} short guided steps across core pages.
  • +
  • - Each step highlights one UI section and tells you what to do there.
  • +
  • - You can skip now and still continue directly to the dashboard.
- {isLastStep ? ( -

- Ready to play? You can open the docs now or continue to the dashboard. -

- ) : null}
{error ? {error} : null} -
- -
diff --git a/apps/web/app/v1/[...path]/route.ts b/apps/web/app/v1/[...path]/route.ts index 11153032..d024104e 100644 --- a/apps/web/app/v1/[...path]/route.ts +++ b/apps/web/app/v1/[...path]/route.ts @@ -36,17 +36,14 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers { const target = new Headers(); source.forEach((value, key) => { if (!blocked.has(key.toLowerCase())) { - target.set(key, value); + target.append(key, value); } }); return target; } function resolveApiUpstreamBaseUrl(): string { - const explicit = - process.env.API_URL?.trim() || - process.env.API_INTERNAL_URL?.trim() || - process.env.NEXT_PUBLIC_API_URL?.trim(); + const explicit = process.env.API_URL?.trim() || process.env.API_INTERNAL_URL?.trim(); if (explicit) { return explicit.endsWith("/") ? explicit.slice(0, -1) : explicit; diff --git a/apps/web/app/workforce/page.tsx b/apps/web/app/workforce/page.tsx index 54ff86fe..f66bcf24 100644 --- a/apps/web/app/workforce/page.tsx +++ b/apps/web/app/workforce/page.tsx @@ -1,5 +1,5 @@ -import { WorkforcePage } from "@/components/workforce/workforce-page"; +import { redirect } from "next/navigation"; export default function WorkforceRoute() { - return ; + redirect("/buildings"); } diff --git a/apps/web/package.json b/apps/web/package.json index 57efca4a..5df8cdca 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,10 @@ }, "dependencies": { "@corpsim/shared": "workspace:*", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.4", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.1", "better-auth": "^1.4.18", @@ -28,7 +31,7 @@ "@types/react-dom": "^18.3.1", "autoprefixer": "^10.4.20", "eslint-config-next": "^15.5.10", - "postcss": "^8.4.49", + "postcss": "8.4.49", "tailwindcss": "^3.4.17" } } diff --git a/apps/web/src/components/admin/admin-page.tsx b/apps/web/src/components/admin/admin-page.tsx index f5564426..a0c9b4bb 100644 --- a/apps/web/src/components/admin/admin-page.tsx +++ b/apps/web/src/components/admin/admin-page.tsx @@ -46,6 +46,11 @@ interface SupportAccount { const MAIN_ADMIN_EMAIL = "admin@corpsim.local"; const STALE_IMPORT_TICK_THRESHOLD = 5; + +function isSeededExampleAccount(email: string): boolean { + return email.trim().toLowerCase().endsWith("@example.com"); +} + export function AdminPage() { const [users, setUsers] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -81,7 +86,8 @@ export function AdminPage() { }); if (result.data) { - setUsers(result.data.users as unknown as UserData[]); + const loadedUsers = result.data.users as unknown as UserData[]; + setUsers(loadedUsers.filter((user) => !isSeededExampleAccount(user.email))); } else if (result.error) { showToast({ title: "Failed to load users", diff --git a/apps/web/src/components/auth/auth-route-gate.tsx b/apps/web/src/components/auth/auth-route-gate.tsx index 95e7dc88..01286229 100644 --- a/apps/web/src/components/auth/auth-route-gate.tsx +++ b/apps/web/src/components/auth/auth-route-gate.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { usePathname, useRouter } from "next/navigation"; +import { Suspense, useEffect, useMemo, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { OnboardingStatus } from "@corpsim/shared"; import { getOnboardingStatus } from "@/lib/api"; import { authClient } from "@/lib/auth-client"; @@ -16,6 +16,8 @@ import { } from "@/lib/auth-routes"; import { isAdminRole, isModeratorRole, isStaffRole } from "@/lib/roles"; +const GUIDED_TUTORIAL_ALLOWED_PAGES = new Set(["/overview", "/market", "/production", "/inventory"]); + function resolveSafeNextPath(raw: string | null): string | null { if (!raw || !raw.startsWith("/")) { return null; @@ -34,15 +36,15 @@ function FullscreenMessage({ message }: { message: string }) { ); } -export function AuthRouteGate({ children }: { children: React.ReactNode }) { +function AuthRouteGateContent({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const searchParams = useSearchParams(); const router = useRouter(); - const nextPathFromQuery = (() => { - if (typeof window === "undefined") { - return null; - } - return resolveSafeNextPath(new URLSearchParams(window.location.search).get("next")); - })(); + const nextPathFromQuery = useMemo( + () => resolveSafeNextPath(searchParams.get("next")), + [searchParams] + ); + const isGuidedTutorialMode = searchParams.get("tutorial") === "1"; const { data: session, isPending } = authClient.useSession(); const [onboardingStatus, setOnboardingStatus] = useState(null); const [onboardingStatusPathname, setOnboardingStatusPathname] = useState(null); @@ -149,6 +151,9 @@ export function AuthRouteGate({ children }: { children: React.ReactNode }) { if (isTutorialPage(pathname) || isProfilePage(pathname)) { return null; } + if (isGuidedTutorialMode && GUIDED_TUTORIAL_ALLOWED_PAGES.has(pathname)) { + return null; + } return "/tutorial"; } @@ -164,6 +169,7 @@ export function AuthRouteGate({ children }: { children: React.ReactNode }) { onboardingStatus, onboardingStatusPathname, pathname, + isGuidedTutorialMode, session?.user?.id ]); @@ -192,3 +198,11 @@ export function AuthRouteGate({ children }: { children: React.ReactNode }) { return <>{children}; } + +export function AuthRouteGate({ children }: { children: React.ReactNode }) { + return ( + }> + {children} + + ); +} diff --git a/apps/web/src/components/buildings/acquire-building-dialog.tsx b/apps/web/src/components/buildings/acquire-building-dialog.tsx new file mode 100644 index 00000000..0fec61da --- /dev/null +++ b/apps/web/src/components/buildings/acquire-building-dialog.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useActiveCompany } from "@/components/company/active-company-provider"; +import { useToastManager } from "@/components/ui/toast-manager"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + BuildingType, + BuildingTypeDefinition, + RegionSummary, + acquireBuilding, + getBuildingTypeDefinitions, + listRegions +} from "@/lib/api"; +import { formatCents } from "@/lib/format"; + +interface AcquireBuildingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +export function AcquireBuildingDialog({ + open, + onOpenChange, + onSuccess +}: AcquireBuildingDialogProps) { + const { activeCompany, activeCompanyId } = useActiveCompany(); + const { showToast } = useToastManager(); + const [definitions, setDefinitions] = useState([]); + const [regions, setRegions] = useState([]); + const [selectedBuildingType, setSelectedBuildingType] = useState(""); + const [selectedRegionId, setSelectedRegionId] = useState(""); + const [buildingName, setBuildingName] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const loadData = useCallback(async () => { + try { + const [defs, regs] = await Promise.all([ + getBuildingTypeDefinitions(), + listRegions() + ]); + setDefinitions(defs); + setRegions(regs); + } catch (caught) { + console.error("Failed to load building data:", caught); + } + }, []); + + useEffect(() => { + if (open) { + void loadData(); + setSelectedRegionId(activeCompany?.regionId ?? ""); + setSelectedBuildingType(""); + setBuildingName(""); + } + }, [open, loadData, activeCompany?.regionId]); + + const selectedDefinition = definitions.find((d) => d.buildingType === selectedBuildingType); + + const handleSubmit = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + + if (!activeCompanyId || !selectedBuildingType || !selectedRegionId) { + return; + } + + setIsSubmitting(true); + try { + await acquireBuilding({ + companyId: activeCompanyId, + regionId: selectedRegionId, + buildingType: selectedBuildingType as BuildingType, + name: buildingName || undefined + }); + + showToast({ + title: "Building Acquired", + description: `Successfully acquired ${selectedDefinition?.name ?? selectedBuildingType}`, + variant: "success" + }); + + onSuccess(); + } catch (caught) { + showToast({ + title: "Acquisition Failed", + description: caught instanceof Error ? caught.message : "Failed to acquire building", + variant: "error" + }); + } finally { + setIsSubmitting(false); + } + }, + [ + activeCompanyId, + selectedBuildingType, + selectedRegionId, + buildingName, + selectedDefinition, + onSuccess, + showToast + ] + ); + + const currentCash = activeCompany?.cashCents + ? BigInt(activeCompany.cashCents) + : BigInt(0); + const acquisitionCost = selectedDefinition + ? BigInt(selectedDefinition.acquisitionCostCents) + : BigInt(0); + const canAfford = currentCash >= acquisitionCost; + + return ( + + +
+ + Acquire Building + + Purchase a new building to expand your operations + + + +
+
+ + +
+ + {selectedDefinition && ( +
+

{selectedDefinition.name}

+

{selectedDefinition.description}

+
+
+

Acquisition Cost

+

+ {formatCents(selectedDefinition.acquisitionCostCents)} +

+
+
+

Weekly Operating Cost

+

+ {formatCents(selectedDefinition.weeklyOperatingCostCents)} +

+
+
+

Capacity Slots

+

{selectedDefinition.capacitySlots}

+
+ {selectedDefinition.storageCapacity !== undefined && ( +
+

Storage Capacity

+

+{selectedDefinition.storageCapacity} items

+
+ )} +
+
+

Your Cash

+

+ {formatCents(currentCash.toString())} +

+ {!canAfford && ( +

Insufficient funds

+ )} +
+
+ )} + +
+ + +
+ +
+ + setBuildingName(e.target.value)} + /> +
+
+ + + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/buildings/buildings-page.tsx b/apps/web/src/components/buildings/buildings-page.tsx new file mode 100644 index 00000000..371a594c --- /dev/null +++ b/apps/web/src/components/buildings/buildings-page.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useActiveCompany } from "@/components/company/active-company-provider"; +import { useWorldHealth } from "@/components/layout/world-health-provider"; +import { useToastManager } from "@/components/ui/toast-manager"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { + BuildingRecord, + BuildingStatus, + BuildingTypeDefinition, + listBuildings, + getBuildingTypeDefinitions, + reactivateBuilding +} from "@/lib/api"; +import { formatCents } from "@/lib/format"; +import { UI_COPY } from "@/lib/ui-copy"; +import { AcquireBuildingDialog } from "./acquire-building-dialog"; + +const STATUS_BADGE_VARIANTS: Record = { + ACTIVE: "default", + INACTIVE: "danger", + CONSTRUCTION: "muted" +}; + +export function BuildingsPage() { + const { activeCompany, activeCompanyId } = useActiveCompany(); + const { health } = useWorldHealth(); + const { showToast } = useToastManager(); + const [buildings, setBuildings] = useState([]); + const [definitions, setDefinitions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [acquireDialogOpen, setAcquireDialogOpen] = useState(false); + + const loadBuildings = useCallback(async () => { + if (!activeCompanyId) { + setBuildings([]); + return; + } + + setIsLoading(true); + try { + const buildingRecords = await listBuildings({ companyId: activeCompanyId }); + setBuildings(buildingRecords); + setError(null); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Failed to load buildings"); + } finally { + setIsLoading(false); + } + }, [activeCompanyId]); + + const loadDefinitions = useCallback(async () => { + try { + const defs = await getBuildingTypeDefinitions(); + setDefinitions(defs); + } catch (caught) { + console.error("Failed to load building definitions:", caught); + } + }, []); + + useEffect(() => { + void loadBuildings(); + }, [loadBuildings]); + + useEffect(() => { + void loadDefinitions(); + }, [loadDefinitions]); + + useEffect(() => { + const tick = health?.currentTick; + if (tick === undefined || !activeCompanyId) { + return; + } + + const timeout = setTimeout(() => { + void loadBuildings(); + }, 500); + + return () => clearTimeout(timeout); + }, [health?.currentTick, loadBuildings, activeCompanyId]); + + const groupedBuildings = useMemo(() => { + const byRegion: Record = {}; + + for (const building of buildings) { + const key = building.region.name; + if (!byRegion[key]) { + byRegion[key] = []; + } + byRegion[key].push(building); + } + + return byRegion; + }, [buildings]); + + const handleReactivate = useCallback( + async (buildingId: string) => { + try { + await reactivateBuilding(buildingId); + showToast({ + title: "Building Reactivated", + variant: "success" + }); + void loadBuildings(); + } catch (caught) { + showToast({ + title: "Reactivation Failed", + description: caught instanceof Error ? caught.message : "Failed to reactivate building", + variant: "error" + }); + } + }, + [loadBuildings, showToast] + ); + + const handleAcquireSuccess = useCallback(() => { + setAcquireDialogOpen(false); + void loadBuildings(); + }, [loadBuildings]); + + if (!activeCompanyId) { + return ( + + + Buildings + + +

{UI_COPY.common.noCompanySelected}

+
+
+ ); + } + + return ( +
+ + + Buildings + + + +

+ Active company: {activeCompany?.name ?? UI_COPY.common.noCompanySelected} +

+ + {error && ( +
+ {error} +
+ )} + + {isLoading && buildings.length === 0 ? ( +

Loading buildings...

+ ) : buildings.length === 0 ? ( +

+ No buildings owned. Click "Acquire Building" to purchase your first facility. +

+ ) : ( +
+ {Object.entries(groupedBuildings).map(([regionName, regionBuildings]) => ( +
+

{regionName}

+ + + + Type + Name + Status + Weekly Cost + Capacity + Actions + + + + {regionBuildings.map((building) => { + const definition = definitions.find( + (d) => d.buildingType === building.buildingType + ); + return ( + + + {definition?.name ?? building.buildingType} + {definition?.category && ( + + ({definition.category}) + + )} + + + {building.name || "—"} + + + + {building.status} + + {building.status === "INACTIVE" && ( +

+ Cannot afford operating costs +

+ )} +
+ + {formatCents(building.weeklyOperatingCostCents)} + + + {building.capacitySlots} + {definition?.category === "STORAGE" && ( + + {" "} + (+{definition.storageCapacity} storage) + + )} + + + {building.status === "INACTIVE" && ( + + )} + +
+ ); + })} +
+
+
+ ))} +
+ )} +
+
+ + +
+ ); +} diff --git a/apps/web/src/components/buildings/storage-meter.tsx b/apps/web/src/components/buildings/storage-meter.tsx new file mode 100644 index 00000000..213ca9c1 --- /dev/null +++ b/apps/web/src/components/buildings/storage-meter.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RegionalStorageInfo, getRegionalStorageInfo } from "@/lib/api"; +import { Progress } from "@/components/ui/progress"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertTriangle, AlertCircle } from "lucide-react"; + +interface StorageMeterProps { + companyId: string; + regionId: string; + className?: string; + showDetails?: boolean; +} + +export function StorageMeter({ + companyId, + regionId, + className = "", + showDetails = true +}: StorageMeterProps) { + const [info, setInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + const loadInfo = async () => { + setIsLoading(true); + try { + const storageInfo = await getRegionalStorageInfo(companyId, regionId); + if (mounted) { + setInfo(storageInfo); + setError(null); + } + } catch (caught) { + if (mounted) { + setError(caught instanceof Error ? caught.message : "Failed to load storage info"); + } + } finally { + if (mounted) { + setIsLoading(false); + } + } + }; + + void loadInfo(); + + return () => { + mounted = false; + }; + }, [companyId, regionId]); + + if (isLoading) { + return ( +
+
Loading storage info...
+
+ ); + } + + if (error || !info) { + return null; + } + + const percentage = info.usagePercentage; + const warningThreshold = 80; + const criticalThreshold = 95; + const fullThreshold = 100; + + let progressColor = "bg-primary"; + let showWarning = false; + let warningMessage = ""; + let warningIcon = ; + + if (percentage >= fullThreshold) { + progressColor = "bg-destructive"; + showWarning = true; + warningMessage = "Storage is full! Cannot store more items."; + warningIcon = ; + } else if (percentage >= criticalThreshold) { + progressColor = "bg-destructive"; + showWarning = true; + warningMessage = `Storage is ${percentage.toFixed(0)}% full. Critical capacity!`; + } else if (percentage >= warningThreshold) { + progressColor = "bg-yellow-500"; + showWarning = true; + warningMessage = `Storage is ${percentage.toFixed(0)}% full. Consider expanding.`; + } + + return ( +
+ {showDetails && ( +
+ Regional Storage + + {info.currentUsage.toLocaleString()} / {info.maxCapacity.toLocaleString()} + +
+ )} + + + + {showDetails && ( +
+ {percentage.toFixed(1)}% used + {info.warehouseCount > 0 && ( + + {info.warehouseCount} warehouse{info.warehouseCount !== 1 ? "s" : ""} + + )} +
+ )} + + {showWarning && showDetails && ( + = fullThreshold ? "destructive" : "default"} className="mt-2"> +
+ {warningIcon} + {warningMessage} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/dev/dev-catalog-page.tsx b/apps/web/src/components/dev/dev-catalog-page.tsx index 3d2130bd..be56bd31 100644 --- a/apps/web/src/components/dev/dev-catalog-page.tsx +++ b/apps/web/src/components/dev/dev-catalog-page.tsx @@ -3,6 +3,8 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; import { getIconCatalogItemByCode } from "@corpsim/shared"; import { ItemLabel } from "@/components/items/item-label"; +import { ItemQuantityLabel } from "@/components/items/item-quantity-label"; +import { ItemQuantityList } from "@/components/items/item-quantity-list"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -943,21 +945,22 @@ export function DevCatalogPage() { - - {recipe.outputQuantity} - - + {recipe.durationTicks} -
- {recipe.inputs.map((input) => ( -

- {input.quantityPerRun} - -

- ))} -
+ ({ + key: `${recipe.id}-${input.itemId}`, + quantity: input.quantityPerRun, + itemCode: input.item.code, + itemName: input.item.name + }))} + />
))} diff --git a/apps/web/src/components/inventory/inventory-page.tsx b/apps/web/src/components/inventory/inventory-page.tsx index b21f3dec..ce576f74 100644 --- a/apps/web/src/components/inventory/inventory-page.tsx +++ b/apps/web/src/components/inventory/inventory-page.tsx @@ -168,7 +168,7 @@ export function InventoryPage() { return (
- + Inventory @@ -265,7 +265,7 @@ export function InventoryPage() { - + diff --git a/apps/web/src/components/items/item-quantity-label.tsx b/apps/web/src/components/items/item-quantity-label.tsx new file mode 100644 index 00000000..2895f810 --- /dev/null +++ b/apps/web/src/components/items/item-quantity-label.tsx @@ -0,0 +1,30 @@ +import { ItemLabel } from "@/components/items/item-label"; +import { formatQuantityToken } from "@/lib/quantity-controller"; +import { cn } from "@/lib/utils"; + +interface ItemQuantityLabelProps { + quantity: number; + itemCode?: string | null; + itemName: string; + className?: string; + quantityClassName?: string; + itemClassName?: string; +} + +export function ItemQuantityLabel({ + quantity, + itemCode, + itemName, + className, + quantityClassName, + itemClassName +}: ItemQuantityLabelProps) { + return ( + + + {formatQuantityToken(quantity)} + + + + ); +} diff --git a/apps/web/src/components/items/item-quantity-list.tsx b/apps/web/src/components/items/item-quantity-list.tsx new file mode 100644 index 00000000..d2c0fe78 --- /dev/null +++ b/apps/web/src/components/items/item-quantity-list.tsx @@ -0,0 +1,48 @@ +import { Fragment } from "react"; +import { ItemQuantityLabel } from "@/components/items/item-quantity-label"; +import { cn } from "@/lib/utils"; + +export interface ItemQuantityListEntry { + key: string; + quantity: number; + itemCode?: string | null; + itemName: string; +} + +interface ItemQuantityListProps { + items: ItemQuantityListEntry[]; + className?: string; + itemClassName?: string; + separator?: string; +} + +export function ItemQuantityList({ + items, + className, + itemClassName, + separator = "," +}: ItemQuantityListProps) { + if (items.length === 0) { + return --; + } + + return ( +
+ {items.map((entry, index) => ( + + {index > 0 ? ( + + ) : null} + + + ))} +
+ ); +} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index adfad978..1062ca5c 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -10,11 +10,13 @@ import { InventoryPreviewShortcut } from "./inventory-preview-shortcut"; import { PageSearchCommand } from "./page-search-command"; import { ProfilePanel } from "./profile-panel"; import { QuickNavigationShortcuts } from "./quick-navigation-shortcuts"; +import { ResearchCompletionNotifier } from "./research-completion-notifier"; import { ShortcutsHelpShortcut } from "./shortcuts-help-shortcut"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; import { useWorldHealth } from "./world-health-provider"; import { isAuthPage, isOnboardingPage, isProfilePage, isTutorialPage } from "@/lib/auth-routes"; +import { GuidedTutorialOverlay } from "@/components/tutorial/guided-tutorial-overlay"; export function AppShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); @@ -94,7 +96,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { + +
diff --git a/apps/web/src/components/layout/app-version-badge.tsx b/apps/web/src/components/layout/app-version-badge.tsx index 1a4f18c4..e98470f4 100644 --- a/apps/web/src/components/layout/app-version-badge.tsx +++ b/apps/web/src/components/layout/app-version-badge.tsx @@ -1,11 +1,13 @@ "use client"; import { useEffect, useState } from "react"; +import { fetchDiscordServerUrlFromMeta, getDiscordServerUrl } from "@/lib/public-links"; import { getDisplayVersion } from "@/lib/version"; import { cn } from "@/lib/utils"; export function AppVersionBadge({ className }: { className?: string }) { const [version, setVersion] = useState(null); + const [discordServerUrl, setDiscordServerUrl] = useState(() => getDiscordServerUrl()); useEffect(() => { let active = true; @@ -32,9 +34,45 @@ export function AppVersionBadge({ className }: { className?: string }) { }; }, []); - if (!version) { - return null; - } + useEffect(() => { + if (discordServerUrl) { + return; + } + + const controller = new AbortController(); + void fetchDiscordServerUrlFromMeta(controller.signal).then((url) => { + if (!url) { + return; + } + setDiscordServerUrl(url); + }); + + return () => { + controller.abort(); + }; + }, [discordServerUrl]); - return

CorpSim ERP v{version}

; + return ( +
+ + {discordServerUrl ? ( + + {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} - ALPHA + + ) : ( + {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} - ALPHA + )} + + Preview build provided as-is with no player support. Data may be reset or wiped at any time until beta. + {discordServerUrl ? " Click the version tag to join our Discord server for updates." : ""} + + +
+ ); } diff --git a/apps/web/src/components/layout/research-completion-notifier.tsx b/apps/web/src/components/layout/research-completion-notifier.tsx new file mode 100644 index 00000000..97a74b8d --- /dev/null +++ b/apps/web/src/components/layout/research-completion-notifier.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useActiveCompany } from "@/components/company/active-company-provider"; +import { useUiSfx } from "@/components/layout/ui-sfx-provider"; +import { useWorldHealth } from "@/components/layout/world-health-provider"; +import { useToast } from "@/components/ui/toast-manager"; +import { ResearchNode, listResearch } from "@/lib/api"; + +const RESEARCH_REFRESH_DEBOUNCE_MS = 500; +const MAX_RECIPES_IN_TOAST = 3; + +function buildResearchCompletionMessage(completedNodes: ResearchNode[]): string { + const unlockedRecipes = completedNodes.flatMap((node) => node.unlockRecipes); + const uniqueRecipeNames = Array.from( + new Set( + unlockedRecipes + .map((recipe) => recipe.recipeName) + .filter((name): name is string => Boolean(name && name.trim())) + ) + ); + + if (uniqueRecipeNames.length === 0) { + return "Research complete!"; + } + + const displayedNames = uniqueRecipeNames.slice(0, MAX_RECIPES_IN_TOAST); + const remainingCount = uniqueRecipeNames.length - displayedNames.length; + const baseList = displayedNames.join(", "); + const summary = remainingCount > 0 ? `${baseList} + ${remainingCount} more` : baseList; + return `Research complete! Unlocked recipes: ${summary}`; +} + +export function ResearchCompletionNotifier() { + const { activeCompanyId } = useActiveCompany(); + const { health } = useWorldHealth(); + const { play } = useUiSfx(); + const { showToast } = useToast(); + + const [nodes, setNodes] = useState([]); + const statusByNodeIdRef = useRef>(new Map()); + const didPrimeStatusesRef = useRef(false); + + const loadResearch = useCallback( + async (options?: { force?: boolean }) => { + if (!activeCompanyId) { + setNodes([]); + return; + } + + try { + const rows = await listResearch(activeCompanyId, { force: options?.force }); + setNodes(rows); + } catch { + // Ignore background notifier fetch failures and retry on next tick. + } + }, + [activeCompanyId] + ); + + useEffect(() => { + statusByNodeIdRef.current = new Map(); + didPrimeStatusesRef.current = false; + + if (!activeCompanyId) { + setNodes([]); + return; + } + + void loadResearch({ force: true }); + }, [activeCompanyId, loadResearch]); + + const nextResearchCompletionTick = useMemo(() => { + const researching = nodes.filter( + (node) => node.status === "RESEARCHING" && node.tickCompletes !== null + ); + + if (researching.length === 0) { + return null; + } + + return researching.reduce( + (minTick, node) => Math.min(minTick, node.tickCompletes ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER + ); + }, [nodes]); + + useEffect(() => { + if ( + !activeCompanyId || + health?.currentTick === undefined || + nextResearchCompletionTick === null || + health.currentTick < nextResearchCompletionTick + ) { + return; + } + + const timeout = setTimeout(() => { + void loadResearch({ force: true }); + }, RESEARCH_REFRESH_DEBOUNCE_MS); + + return () => clearTimeout(timeout); + }, [activeCompanyId, health?.currentTick, loadResearch, nextResearchCompletionTick]); + + const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node] as const)), [nodes]); + + useEffect(() => { + const nextStatusById = new Map(nodes.map((node) => [node.id, node.status] as const)); + if (!didPrimeStatusesRef.current) { + statusByNodeIdRef.current = nextStatusById; + didPrimeStatusesRef.current = true; + return; + } + + const completedNodes: ResearchNode[] = []; + for (const [nodeId, nextStatus] of nextStatusById.entries()) { + const previousStatus = statusByNodeIdRef.current.get(nodeId); + if (previousStatus !== "COMPLETED" && nextStatus === "COMPLETED") { + const node = nodeById.get(nodeId); + if (node) { + completedNodes.push(node); + } + } + } + + if (completedNodes.length > 0) { + play("event_research_completed"); + showToast({ + title: completedNodes.length === 1 ? completedNodes[0].name : "Research Complete", + description: buildResearchCompletionMessage(completedNodes), + variant: "success", + sound: "none" + }); + } + + statusByNodeIdRef.current = nextStatusById; + }, [nodeById, nodes, play, showToast]); + + return null; +} diff --git a/apps/web/src/components/layout/sidebar-nav.tsx b/apps/web/src/components/layout/sidebar-nav.tsx index 84a8df71..a4d92c16 100644 --- a/apps/web/src/components/layout/sidebar-nav.tsx +++ b/apps/web/src/components/layout/sidebar-nav.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { + Building2, BookOpenText, Box, CircleDollarSign, @@ -19,7 +20,6 @@ import { Wrench, Truck, TrendingUp, - Users } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { SIDEBAR_PAGE_NAVIGATION, APP_PAGE_NAVIGATION } from "@/lib/page-navigation"; @@ -33,9 +33,9 @@ import { isAdminRole, isModeratorRole } from "@/lib/roles"; const NAV_ICON_BY_ROUTE: Record = { "/overview": LayoutDashboard, "/market": TrendingUp, + "/buildings": Building2, "/production": Factory, "/research": FlaskConical, - "/workforce": Users, "/inventory": PackageSearch, "/logistics": Truck, "/contracts": ClipboardList, diff --git a/apps/web/src/components/layout/tick-countdown.tsx b/apps/web/src/components/layout/tick-countdown.tsx new file mode 100644 index 00000000..da99c26d --- /dev/null +++ b/apps/web/src/components/layout/tick-countdown.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Clock } from "lucide-react"; +import { InlineHelp } from "@/components/ui/inline-help"; +import { useWorldHealth } from "./world-health-provider"; + +// Default tick interval (60 seconds) - matches worker default configuration +// TODO: Get this from API configuration endpoint +const DEFAULT_TICK_INTERVAL_MS = 60_000; + +export function TickCountdown() { + const { health } = useWorldHealth(); + const [secondsRemaining, setSecondsRemaining] = useState(null); + + useEffect(() => { + if (!health?.lastAdvancedAt) { + setSecondsRemaining(null); + return; + } + + const updateCountdown = () => { + const lastAdvancedTime = new Date(health.lastAdvancedAt!).getTime(); + const now = Date.now(); + const elapsed = now - lastAdvancedTime; + const remaining = + (DEFAULT_TICK_INTERVAL_MS - (elapsed % DEFAULT_TICK_INTERVAL_MS)) % + DEFAULT_TICK_INTERVAL_MS; + setSecondsRemaining(Math.ceil(remaining / 1000)); + }; + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + + return () => clearInterval(interval); + }, [health?.lastAdvancedAt, health?.currentTick]); + + if (secondsRemaining === null) { + return null; + } + + const helpText = `Time progression: The simulation advances in discrete weeks (ticks). Each week represents ${DEFAULT_TICK_INTERVAL_MS / 1000} seconds of real time. Production jobs, research, and shipments complete when the required number of weeks pass.`; + + return ( +
+ + + Next week in {secondsRemaining}s + + +
+ ); +} diff --git a/apps/web/src/components/layout/top-bar.tsx b/apps/web/src/components/layout/top-bar.tsx index 695cd3d3..d86e9101 100644 --- a/apps/web/src/components/layout/top-bar.tsx +++ b/apps/web/src/components/layout/top-bar.tsx @@ -15,6 +15,7 @@ import { useControlManager } from "./control-manager"; import { PROFILE_PANEL_ID } from "./profile-panel"; import { StatusIndicator } from "./status-indicator"; import { UiSfxSettings } from "./ui-sfx-settings"; +import { TickCountdown } from "./tick-countdown"; import { useWorldHealth } from "./world-health-provider"; export function TopBar() { @@ -38,7 +39,10 @@ export function TopBar() {

{TOP_BAR_TITLES[pathname] ?? "CorpSim"}

-

{formatCadencePoint(health?.currentTick)}

+
+

{formatCadencePoint(health?.currentTick)}

+ +
diff --git a/apps/web/src/components/logistics/logistics-page.tsx b/apps/web/src/components/logistics/logistics-page.tsx index 4ab5d6b8..62361e40 100644 --- a/apps/web/src/components/logistics/logistics-page.tsx +++ b/apps/web/src/components/logistics/logistics-page.tsx @@ -5,6 +5,7 @@ import { useActiveCompany } from "@/components/company/active-company-provider"; import { ItemLabel } from "@/components/items/item-label"; import { useWorldHealth } from "@/components/layout/world-health-provider"; import { useUiSfx } from "@/components/layout/ui-sfx-provider"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -31,6 +32,7 @@ import { import { formatCents } from "@/lib/format"; import { UI_CADENCE_TERMS } from "@/lib/ui-terms"; import { formatCodeLabel, getRegionLabel, UI_COPY } from "@/lib/ui-copy"; +import Link from "next/link"; const SHIPMENT_REFRESH_DEBOUNCE_MS = 600; const SHIPMENT_BASE_FEE_CENTS = Number.parseInt( @@ -714,28 +716,62 @@ export function LogisticsPage() { {showInitialShipmentsSkeleton && pagedInTransit.length === 0 ? : null} - {pagedInTransit.map((shipment) => ( - - - - - - {`${getRegionLabel({ code: shipment.fromRegion.code, name: shipment.fromRegion.name })} -> ${getRegionLabel({ code: shipment.toRegion.code, name: shipment.toRegion.name })}`} - - {shipment.quantity} - {shipment.tickArrives} - - - - - ))} + {pagedInTransit.map((shipment) => { + const isStuck = health?.currentTick !== undefined && shipment.tickArrives < health.currentTick; + return ( + + + + + +
+ + {`${getRegionLabel({ code: shipment.fromRegion.code, name: shipment.fromRegion.name })} -> ${getRegionLabel({ code: shipment.toRegion.code, name: shipment.toRegion.name })}`} + + {isStuck ? ( + + Stuck + + ) : null} +
+
+ {shipment.quantity} + {shipment.tickArrives} + + {isStuck ? ( +
+ + + + +
+ ) : ( + + )} +
+
+ ); + })} {!showInitialShipmentsSkeleton && pagedInTransit.length === 0 ? ( diff --git a/apps/web/src/components/maintenance/maintenance-overlay.tsx b/apps/web/src/components/maintenance/maintenance-overlay.tsx index ca5d655b..fa5c4b72 100644 --- a/apps/web/src/components/maintenance/maintenance-overlay.tsx +++ b/apps/web/src/components/maintenance/maintenance-overlay.tsx @@ -98,7 +98,11 @@ export function MaintenanceOverlay({ state }: { state: MaintenanceState }) { labelledBy="maintenance-title" describedBy="maintenance-description" > -
+

Operations notice

CorpSim is under maintenance diff --git a/apps/web/src/components/market/market-page.tsx b/apps/web/src/components/market/market-page.tsx index ec949927..405d42a8 100644 --- a/apps/web/src/components/market/market-page.tsx +++ b/apps/web/src/components/market/market-page.tsx @@ -89,6 +89,7 @@ export function MarketPage() { const { activeCompanyId, activeCompany } = useActiveCompany(); const { health, refresh: refreshHealth } = useWorldHealth(); + const [catalogItems, setCatalogItems] = useState([]); const [items, setItems] = useState([]); const [companies, setCompanies] = useState([]); const [regions, setRegions] = useState([]); @@ -115,7 +116,8 @@ export function MarketPage() { const [error, setError] = useState(null); const loadCatalog = useCallback(async (): Promise => { - const [itemRows, regionRows, companyRows] = await Promise.all([ + const [catalogItemRows, selectableItemRows, regionRows, companyRows] = await Promise.all([ + listItems(), listItems(activeCompanyId ?? undefined), listRegions(), listCompanies() @@ -140,7 +142,8 @@ export function MarketPage() { } setUnlockedItemIds(Array.from(unlockedIds)); - setItems(itemRows); + setCatalogItems(catalogItemRows); + setItems(selectableItemRows); setRegions(regionRows); setCompanies(companyRows); let resolvedRegionId = ""; @@ -393,11 +396,11 @@ export function MarketPage() { ); const unlockedItemIdSet = useMemo(() => new Set(unlockedItemIds), [unlockedItemIds]); const sortedSelectableItems = useMemo(() => { - if (unlockedItemIdSet.size === 0) { + if (!activeCompanyId) { return sortedItems; } return sortedItems.filter((item) => unlockedItemIdSet.has(item.id)); - }, [sortedItems, unlockedItemIdSet]); + }, [activeCompanyId, sortedItems, unlockedItemIdSet]); const filteredOrderSelectableItems = useMemo(() => { const needle = deferredOrderItemSearch.trim().toLowerCase(); if (!needle) { @@ -456,8 +459,11 @@ export function MarketPage() { [regions] ); const itemMetaById = useMemo( - () => Object.fromEntries(items.map((item) => [item.id, { code: item.code, name: item.name }])), - [items] + () => + Object.fromEntries( + catalogItems.map((item) => [item.id, { code: item.code, name: item.name }]) + ), + [catalogItems] ); const companyNameById = useMemo( () => Object.fromEntries(companies.map((company) => [company.id, company.name])), @@ -469,6 +475,24 @@ export function MarketPage() { name: activeCompany.regionName }) : null; + const visibleOrderBook = useMemo(() => { + if (!activeCompanyId) { + return orderBook; + } + return orderBook.filter((order) => unlockedItemIdSet.has(order.itemId)); + }, [activeCompanyId, orderBook, unlockedItemIdSet]); + const visibleMyOrders = useMemo(() => { + if (!activeCompanyId) { + return myOrders; + } + return myOrders.filter((order) => unlockedItemIdSet.has(order.itemId)); + }, [activeCompanyId, myOrders, unlockedItemIdSet]); + const visibleTrades = useMemo(() => { + if (!activeCompanyId) { + return trades; + } + return trades.filter((trade) => unlockedItemIdSet.has(trade.itemId)); + }, [activeCompanyId, trades, unlockedItemIdSet]); useEffect(() => { if (orderFilters.itemId && !sortedSelectableItems.some((item) => item.id === orderFilters.itemId)) { @@ -485,12 +509,14 @@ export function MarketPage() { return (
- +
+ +
@@ -604,21 +630,28 @@ export function MarketPage() { Showing first {visibleOrderSelectableItems.length} matching items in order-item dropdown.

) : null} + {activeCompanyId && sortedSelectableItems.length === 0 ? ( +

+ No tradable items are currently unlocked for this company. +

+ ) : null} {error ?

{error}

: null}
- +
+ +
("BUY"); const [selectedItemId, setSelectedItemId] = useState(""); const [itemSearch, setItemSearch] = useState(""); - const [priceInput, setPriceInput] = useState("1.00"); - const [quantityInput, setQuantityInput] = useState("1"); + const [priceInput, setPriceInput] = useState(""); + const [quantityInput, setQuantityInput] = useState(""); const [error, setError] = useState(null); const deferredItemSearch = useDeferredValue(itemSearch); @@ -144,7 +144,7 @@ export function OrderPlacementCard({ setQuantityInput(event.target.value)} - placeholder="1" + placeholder="Enter quantity (e.g., 100)" />

@@ -181,7 +181,7 @@ export function OrderPlacementCard({ setPriceInput(event.target.value)} - placeholder="1.00" + placeholder="Enter price (e.g., 1.50)" />

Enter dollars (for example, 0.80). The order is stored in cents. diff --git a/apps/web/src/components/production/production-page.tsx b/apps/web/src/components/production/production-page.tsx index 943033fd..918baa34 100644 --- a/apps/web/src/components/production/production-page.tsx +++ b/apps/web/src/components/production/production-page.tsx @@ -4,7 +4,8 @@ import { FormEvent, useCallback, useDeferredValue, useEffect, useMemo, useRef, u import { resolveCompanySpecializationCooldownHours } from "@corpsim/shared"; import { Check, ChevronsUpDown } from "lucide-react"; import { useActiveCompany } from "@/components/company/active-company-provider"; -import { ItemLabel } from "@/components/items/item-label"; +import { ItemQuantityLabel } from "@/components/items/item-quantity-label"; +import { ItemQuantityList } from "@/components/items/item-quantity-list"; import { useWorldHealth } from "@/components/layout/world-health-provider"; import { useUiSfx } from "@/components/layout/ui-sfx-provider"; import { Badge } from "@/components/ui/badge"; @@ -78,7 +79,7 @@ export function ProductionPage() { const [recipePage, setRecipePage] = useState(1); const [recipePageSize, setRecipePageSize] = useState<(typeof PRODUCTION_RECIPE_PAGE_SIZE_OPTIONS)[number]>(10); - const [quantityInput, setQuantityInput] = useState("1"); + const [quantityInput, setQuantityInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [isLoadingRecipes, setIsLoadingRecipes] = useState(true); const [isLoadingJobs, setIsLoadingJobs] = useState(true); @@ -331,18 +332,33 @@ export function ProductionPage() { return; } - let hasNewCompletion = false; - for (const jobId of nextIds) { - if (!completedJobIdsRef.current.has(jobId)) { - hasNewCompletion = true; - break; + const newlyCompleted: ProductionJob[] = []; + for (const job of completedJobs) { + if (!completedJobIdsRef.current.has(job.id)) { + newlyCompleted.push(job); } } - if (hasNewCompletion) { + if (newlyCompleted.length > 0) { play("event_production_completed"); + + // Show toast notification for completed jobs + if (newlyCompleted.length === 1) { + const job = newlyCompleted[0]; + showToast({ + title: "Production Complete", + description: `Produced ${job.quantity} × ${job.recipe.outputItem.name}`, + variant: "success" + }); + } else { + showToast({ + title: "Production Complete", + description: `${newlyCompleted.length} production jobs completed`, + variant: "success" + }); + } } completedJobIdsRef.current = nextIds; - }, [completedJobs, play]); + }, [completedJobs, play, showToast]); const showInitialRecipesSkeleton = isLoadingRecipes && !hasLoadedRecipes; const showInitialJobsSkeleton = isLoadingJobs && !hasLoadedJobs; @@ -538,7 +554,7 @@ export function ProductionPage() { - + Start Production @@ -614,7 +630,7 @@ export function ProductionPage() { setQuantityInput(event.target.value)} - placeholder="1" + placeholder="Enter number of runs (e.g., 10)" />

@@ -625,23 +641,25 @@ export function ProductionPage() { Duration: {formatCadenceCount(selectedRecipe.durationTicks)} / run

- Output: {selectedRecipe.outputQuantity}{" "} -

-

Inputs:

-
    - {selectedRecipe.inputs.map((input) => ( -
  • - {input.quantityPerRun} - - / run -
  • - ))} -
+
+

Inputs / run:

+ ({ + key: input.itemId, + quantity: input.quantityPerRun, + itemCode: input.item.code, + itemName: input.item.name + }))} + /> +
) : null} @@ -654,7 +672,7 @@ export function ProductionPage() {
- + Recipes @@ -745,24 +763,22 @@ export function ProductionPage() {

{row.recipe.name}

- - {row.recipe.outputQuantity} - - + {formatCadenceCount(row.recipe.durationTicks)} -
- {row.recipe.inputs.map((input) => ( - - {input.quantityPerRun} - - - ))} -
+ ({ + key: input.itemId, + quantity: input.quantityPerRun, + itemCode: input.item.code, + itemName: input.item.name + }))} + />
))} diff --git a/apps/web/src/components/research/research-page.tsx b/apps/web/src/components/research/research-page.tsx index 056a3481..d9c870f7 100644 --- a/apps/web/src/components/research/research-page.tsx +++ b/apps/web/src/components/research/research-page.tsx @@ -109,8 +109,6 @@ export function ResearchPage() { const [nodePageSize, setNodePageSize] = useState<(typeof RESEARCH_NODE_PAGE_SIZE_OPTIONS)[number]>(20); const deferredNodeSearch = useDeferredValue(nodeSearch); - const statusByNodeIdRef = useRef>(new Map()); - const didPrimeStatusesRef = useRef(false); const hasLoadedResearchRef = useRef(false); const loadResearch = useCallback(async (options?: { force?: boolean; showLoadingState?: boolean }) => { @@ -190,34 +188,8 @@ export function ResearchPage() { return () => clearTimeout(timeout); }, [activeCompanyId, health?.currentTick, loadResearch, nextResearchCompletionTick]); - useEffect(() => { - statusByNodeIdRef.current = new Map(); - didPrimeStatusesRef.current = false; - }, [activeCompanyId]); - - useEffect(() => { - const nextStatusById = new Map(nodes.map((node) => [node.id, node.status] as const)); - if (!didPrimeStatusesRef.current) { - statusByNodeIdRef.current = nextStatusById; - didPrimeStatusesRef.current = true; - return; - } - - let hasNewCompletion = false; - for (const [nodeId, nextStatus] of nextStatusById.entries()) { - const previousStatus = statusByNodeIdRef.current.get(nodeId); - if (previousStatus !== "COMPLETED" && nextStatus === "COMPLETED") { - hasNewCompletion = true; - break; - } - } - if (hasNewCompletion) { - play("event_research_completed"); - } - statusByNodeIdRef.current = nextStatusById; - }, [nodes, play]); - const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node] as const)), [nodes]); + const selectedNode = selectedNodeId ? nodeById.get(selectedNodeId) ?? null : null; const filteredNodes = useMemo(() => { diff --git a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx new file mode 100644 index 00000000..9056b4ff --- /dev/null +++ b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { completeOnboardingTutorial } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { GUIDED_TUTORIAL_STEPS } from "./guided-tutorial-steps"; + +const SPOTLIGHT_PADDING_PX = 8; +const CARD_ESTIMATED_HEIGHT_PX = 220; +const CARD_MAX_WIDTH_PX = 360; + +function clampStepIndex(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(Math.max(Math.trunc(value), 0), GUIDED_TUTORIAL_STEPS.length - 1); +} + +function buildTutorialHref(route: string, stepIndex: number): string { + const params = new URLSearchParams(); + params.set("tutorial", "1"); + params.set("tutorialStep", String(stepIndex)); + return `${route}?${params.toString()}`; +} + +function readErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return "Unable to finish tutorial right now. Please try again."; +} + +function GuidedTutorialOverlayContent() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const router = useRouter(); + const isTutorialActive = searchParams.get("tutorial") === "1"; + const stepIndex = useMemo( + () => clampStepIndex(Number.parseInt(searchParams.get("tutorialStep") ?? "0", 10)), + [searchParams] + ); + const step = GUIDED_TUTORIAL_STEPS[stepIndex]; + const [targetRect, setTargetRect] = useState(null); + const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }); + const [isSubmitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const isFirstStep = stepIndex === 0; + const isLastStep = stepIndex >= GUIDED_TUTORIAL_STEPS.length - 1; + + useEffect(() => { + if (!isTutorialActive) { + return; + } + + const updateViewport = () => + setViewportSize({ width: window.innerWidth, height: window.innerHeight }); + + updateViewport(); + window.addEventListener("resize", updateViewport); + return () => window.removeEventListener("resize", updateViewport); + }, [isTutorialActive]); + + useEffect(() => { + if (!isTutorialActive) { + return; + } + + if (pathname !== step.route) { + router.replace(buildTutorialHref(step.route, stepIndex)); + } + }, [isTutorialActive, pathname, router, step.route, stepIndex]); + + useEffect(() => { + if (!isTutorialActive || pathname !== step.route) { + setTargetRect(null); + return; + } + + const query = `[data-tutorial-id="${step.targetId}"]`; + const updateRect = () => { + const element = document.querySelector(query); + if (!element) { + setTargetRect(null); + return; + } + setTargetRect(element.getBoundingClientRect()); + }; + + updateRect(); + const interval = window.setInterval(updateRect, 250); + window.addEventListener("scroll", updateRect, true); + window.addEventListener("resize", updateRect); + + return () => { + window.clearInterval(interval); + window.removeEventListener("scroll", updateRect, true); + window.removeEventListener("resize", updateRect); + }; + }, [isTutorialActive, pathname, step.route, step.targetId]); + + const spotlightStyle = useMemo(() => { + if (!targetRect) { + return null; + } + + const top = Math.max(8, targetRect.top - SPOTLIGHT_PADDING_PX); + const left = Math.max(8, targetRect.left - SPOTLIGHT_PADDING_PX); + const width = targetRect.width + SPOTLIGHT_PADDING_PX * 2; + const height = targetRect.height + SPOTLIGHT_PADDING_PX * 2; + + return { + top, + left, + width, + height + }; + }, [targetRect]); + + const cardStyle = useMemo(() => { + if (!targetRect || viewportSize.width <= 0 || viewportSize.height <= 0) { + return { + right: 16, + bottom: 16, + width: CARD_MAX_WIDTH_PX + }; + } + + let top = targetRect.bottom + 12; + if (top + CARD_ESTIMATED_HEIGHT_PX > viewportSize.height - 16) { + top = targetRect.top - CARD_ESTIMATED_HEIGHT_PX - 12; + } + top = Math.max(16, top); + + let left = targetRect.left; + const width = Math.min(CARD_MAX_WIDTH_PX, Math.max(280, viewportSize.width - 32)); + if (left + width > viewportSize.width - 16) { + left = viewportSize.width - width - 16; + } + left = Math.max(16, left); + + return { + top, + left, + width + }; + }, [targetRect, viewportSize.height, viewportSize.width]); + + const goToStep = useCallback( + (nextStepIndex: number) => { + const clampedIndex = clampStepIndex(nextStepIndex); + const nextStep = GUIDED_TUTORIAL_STEPS[clampedIndex]; + setError(null); + router.replace(buildTutorialHref(nextStep.route, clampedIndex)); + }, + [router] + ); + + const completeTutorial = useCallback(async () => { + if (isSubmitting) { + return; + } + + setSubmitting(true); + setError(null); + try { + await completeOnboardingTutorial(); + router.replace("/overview"); + router.refresh(); + } catch (caught) { + setError(readErrorMessage(caught)); + } finally { + setSubmitting(false); + } + }, [isSubmitting, router]); + + if (!isTutorialActive) { + return null; + } + + return ( +
+ {spotlightStyle ? ( +
+ ) : ( +
+ )} + +
+

+ Guided Tutorial {stepIndex + 1}/{GUIDED_TUTORIAL_STEPS.length} +

+

{step.title}

+

{step.description}

+ + {targetRect ? null : ( +

+ Waiting for this section to load on the page. +

+ )} + + {error ?

{error}

: null} + +
+ + +
+ + {isLastStep ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + +export function GuidedTutorialOverlay() { + return ( + + + + ); +} diff --git a/apps/web/src/components/tutorial/guided-tutorial-steps.ts b/apps/web/src/components/tutorial/guided-tutorial-steps.ts new file mode 100644 index 00000000..c69d61f2 --- /dev/null +++ b/apps/web/src/components/tutorial/guided-tutorial-steps.ts @@ -0,0 +1,65 @@ +export interface GuidedTutorialStep { + route: string; + targetId: string; + title: string; + description: string; +} + +export const GUIDED_TUTORIAL_STEPS: GuidedTutorialStep[] = [ + { + route: "/overview", + targetId: "overview-company", + title: "Confirm your active company", + description: + "This card shows your current company context. These values are company-specific." + }, + { + route: "/overview", + targetId: "overview-kpis", + title: "Read the world pulse", + description: + "These metrics summarize the full simulation (all companies), not just your company." + }, + { + route: "/overview", + targetId: "overview-integrity", + title: "Watch system integrity", + description: "If there are issues here, investigate before scaling operations." + }, + { + route: "/market", + targetId: "market-order-placement", + title: "Place buy and sell orders", + description: "This is where you create market orders for the active company." + }, + { + route: "/market", + targetId: "market-order-book", + title: "Read the order book", + description: "Use this table to inspect current prices, quantity, and market depth." + }, + { + route: "/production", + targetId: "production-start", + title: "Start production runs", + description: "Pick a recipe and quantity, then launch jobs from this panel." + }, + { + route: "/production", + targetId: "production-recipes", + title: "Review recipes first", + description: "Check output, duration, and required inputs before committing runs." + }, + { + route: "/inventory", + targetId: "inventory-filters", + title: "Filter your inventory view", + description: "Search and region filters help you focus the exact stock you need." + }, + { + route: "/inventory", + targetId: "inventory-table", + title: "Track available stock", + description: "Use quantity, reserved, and available values to avoid production stalls." + } +]; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 00000000..04d5163a --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription +}; diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 00000000..6ac6d6aa --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx new file mode 100644 index 00000000..12e3e72b --- /dev/null +++ b/apps/web/src/components/ui/progress.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; +import { cn } from "@/lib/utils"; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + indicatorClassName?: string; + } +>(({ className, value, indicatorClassName, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx index 639b11e1..11286b25 100644 --- a/apps/web/src/components/views/overview/overview-view.tsx +++ b/apps/web/src/components/views/overview/overview-view.tsx @@ -1,16 +1,39 @@ "use client"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { useActiveCompany } from "@/components/company/active-company-provider"; import { useWorldHealth } from "@/components/layout/world-health-provider"; import { formatCents, formatInt } from "@/lib/format"; +import { fetchDiscordServerUrlFromMeta, getDiscordServerUrl } from "@/lib/public-links"; import { UI_CADENCE_TERMS } from "@/lib/ui-terms"; -import { getDocumentationUrl, UI_COPY } from "@/lib/ui-copy"; +import { formatCodeLabel, getDocumentationUrl, UI_COPY } from "@/lib/ui-copy"; export function OverviewView() { const { health } = useWorldHealth(); + const { activeCompany } = useActiveCompany(); + const [discordServerUrl, setDiscordServerUrl] = useState(() => getDiscordServerUrl()); + + useEffect(() => { + if (discordServerUrl) { + return; + } + + const controller = new AbortController(); + void fetchDiscordServerUrlFromMeta(controller.signal).then((url) => { + if (!url) { + return; + } + setDiscordServerUrl(url); + }); + + return () => { + controller.abort(); + }; + }, [discordServerUrl]); if (!health) { return
Loading overview metrics...
; @@ -18,16 +41,44 @@ export function OverviewView() { const kpis = [ { label: `Current ${UI_CADENCE_TERMS.singularTitle}`, value: formatInt(health.currentTick) }, - { label: "Open Orders", value: formatInt(health.ordersOpenCount) }, - { label: "Trades (Last 100)", value: formatInt(health.tradesLast100Count) }, - { label: "Companies", value: formatInt(health.companiesCount) }, - { label: "Total Cash", value: formatCents(health.sumCashCents) }, - { label: "Reserved Cash", value: formatCents(health.sumReservedCashCents) } + { label: "World Open Orders", value: formatInt(health.ordersOpenCount) }, + { label: "World Trades (Last 100)", value: formatInt(health.tradesLast100Count) }, + { label: "World Companies", value: formatInt(health.companiesCount) }, + { label: "World Total Cash", value: formatCents(health.sumCashCents) }, + { label: "World Reserved Cash", value: formatCents(health.sumReservedCashCents) } ]; return (
-
+ + + Active Company Snapshot + + +
+

Company

+

{activeCompany?.name ?? UI_COPY.common.noCompanySelected}

+
+
+

Region

+

{activeCompany?.regionName ?? "-"}

+
+
+

Specialization

+

+ {activeCompany ? formatCodeLabel(activeCompany.specialization) : "-"} +

+
+
+

Company Cash

+

+ {activeCompany?.cashCents ? formatCents(activeCompany.cashCents) : "Hidden/Unavailable"} +

+
+
+
+ +
{kpis.map((kpi) => ( @@ -40,7 +91,7 @@ export function OverviewView() { ))}
- +
System Integrity @@ -68,6 +119,28 @@ export function OverviewView() { + +
+ Alpha Preview Notice + ALPHA +
+
+ +

+ This preview is provided as-is with no player support. Progress and economy data may be + reset or wiped at any time until beta. +

+ {discordServerUrl ? ( + + ) : null} +
+
+ + {UI_COPY.documentation.title} diff --git a/apps/web/src/components/workforce/workforce-page.tsx b/apps/web/src/components/workforce/workforce-page.tsx index ffe3dce0..cf3e6596 100644 --- a/apps/web/src/components/workforce/workforce-page.tsx +++ b/apps/web/src/components/workforce/workforce-page.tsx @@ -52,12 +52,12 @@ export function WorkforcePage() { const { health } = useWorldHealth(); const [workforce, setWorkforce] = useState(null); const [allocationDraft, setAllocationDraft] = useState({ - operationsPct: "40", - researchPct: "20", - logisticsPct: "20", - corporatePct: "20" + operationsPct: "", + researchPct: "", + logisticsPct: "", + corporatePct: "" }); - const [capacityDeltaInput, setCapacityDeltaInput] = useState("0"); + const [capacityDeltaInput, setCapacityDeltaInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [hasLoadedWorkforce, setHasLoadedWorkforce] = useState(false); const [isSavingAllocation, setIsSavingAllocation] = useState(false); @@ -207,7 +207,7 @@ export function WorkforcePage() { deltaCapacity }); await loadWorkforce(); - setCapacityDeltaInput("0"); + setCapacityDeltaInput(""); setError(null); showToast({ title: "Capacity request submitted", @@ -246,6 +246,9 @@ export function WorkforcePage() { Organizational Capacity +

+ Workforce capacity determines production speed and research efficiency. Higher capacity allows faster operations, but increases weekly salary costs. Allocation percentages control which departments receive speed bonuses. +

@@ -278,42 +281,69 @@ export function WorkforcePage() { Allocation Controls +

+ Distribute your workforce across departments. Higher allocation in each area provides speed bonuses. Total must equal 100%. +

- - setAllocationDraft((current) => ({ ...current, operationsPct: event.target.value })) - } - placeholder="Operations %" - inputMode="numeric" - /> - - setAllocationDraft((current) => ({ ...current, researchPct: event.target.value })) - } - placeholder="Research %" - inputMode="numeric" - /> - - setAllocationDraft((current) => ({ ...current, logisticsPct: event.target.value })) - } - placeholder="Logistics %" - inputMode="numeric" - /> - - setAllocationDraft((current) => ({ ...current, corporatePct: event.target.value })) - } - placeholder="Corporate %" - inputMode="numeric" - /> - @@ -345,7 +375,7 @@ export function WorkforcePage() { setCapacityDeltaInput(event.target.value)} - placeholder="Delta capacity" + placeholder="Enter change (e.g., +50 or -20)" inputMode="numeric" />