diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md new file mode 100644 index 0000000..cd9a83b --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -0,0 +1,83 @@ +--- +name: gitnexus-cli +description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" +--- + +# GitNexus CLI Commands + +All commands work via `npx` — no global install required. + +## Commands + +### analyze — Build or refresh the index + +```bash +npx gitnexus analyze +``` + +Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. + +| Flag | Effect | +| -------------- | ---------------------------------------------------------------- | +| `--force` | Force full re-index even if up to date | +| `--embeddings` | Enable embedding generation for semantic search (off by default) | +| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | + +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. + +### status — Check index freshness + +```bash +npx gitnexus status +``` + +Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. + +### clean — Delete the index + +```bash +npx gitnexus clean +``` + +Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. + +| Flag | Effect | +| --------- | ------------------------------------------------- | +| `--force` | Skip confirmation prompt | +| `--all` | Clean all indexed repos, not just the current one | + +### wiki — Generate documentation from the graph + +```bash +npx gitnexus wiki +``` + +Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). + +| Flag | Effect | +| ------------------- | ----------------------------------------- | +| `--force` | Force full regeneration | +| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--base-url ` | LLM API base URL | +| `--api-key ` | LLM API key | +| `--concurrency ` | Parallel LLM calls (default: 3) | +| `--gist` | Publish wiki as a public GitHub Gist | + +### list — Show all indexed repos + +```bash +npx gitnexus list +``` + +Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. + +## After Indexing + +1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded +2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task + +## Troubleshooting + +- **"Not inside a git repository"**: Run from a directory inside a git repo +- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server +- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md new file mode 100644 index 0000000..9510b97 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -0,0 +1,89 @@ +--- +name: gitnexus-debugging +description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\"" +--- + +# Debugging with GitNexus + +## When to Use + +- "Why is this function failing?" +- "Trace where this error comes from" +- "Who calls this method?" +- "This endpoint returns 500" +- Investigating bugs, errors, or unexpected behavior + +## Workflow + +``` +1. gitnexus_query({query: ""}) → Find related execution flows +2. gitnexus_context({name: ""}) → See callers/callees/processes +3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow +4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] Understand the symptom (error message, unexpected behavior) +- [ ] gitnexus_query for error text or related code +- [ ] Identify the suspect function from returned processes +- [ ] gitnexus_context to see callers and callees +- [ ] Trace execution flow via process resource if applicable +- [ ] gitnexus_cypher for custom call chain traces if needed +- [ ] Read source files to confirm root cause +``` + +## Debugging Patterns + +| Symptom | GitNexus Approach | +| -------------------- | ---------------------------------------------------------- | +| Error message | `gitnexus_query` for error text → `context` on throw sites | +| Wrong return value | `context` on the function → trace callees for data flow | +| Intermittent failure | `context` → look for external calls, async deps | +| Performance issue | `context` → find symbols with many callers (hot paths) | +| Recent regression | `detect_changes` to see what your changes affect | + +## Tools + +**gitnexus_query** — find code related to error: + +``` +gitnexus_query({query: "payment validation error"}) +→ Processes: CheckoutFlow, ErrorHandling +→ Symbols: validatePayment, handlePaymentError, PaymentException +``` + +**gitnexus_context** — full context for a suspect: + +``` +gitnexus_context({name: "validatePayment"}) +→ Incoming calls: processCheckout, webhookHandler +→ Outgoing calls: verifyCard, fetchRates (external API!) +→ Processes: CheckoutFlow (step 3/7) +``` + +**gitnexus_cypher** — custom call chain traces: + +```cypher +MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) +RETURN [n IN nodes(path) | n.name] AS chain +``` + +## Example: "Payment endpoint returns 500 intermittently" + +``` +1. gitnexus_query({query: "payment error handling"}) + → Processes: CheckoutFlow, ErrorHandling + → Symbols: validatePayment, handlePaymentError + +2. gitnexus_context({name: "validatePayment"}) + → Outgoing calls: verifyCard, fetchRates (external API!) + +3. READ gitnexus://repo/my-app/process/CheckoutFlow + → Step 3: validatePayment → calls fetchRates (external) + +4. Root cause: fetchRates calls external API without proper timeout +``` diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md new file mode 100644 index 0000000..927a4e4 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md @@ -0,0 +1,78 @@ +--- +name: gitnexus-exploring +description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\"" +--- + +# Exploring Codebases with GitNexus + +## When to Use + +- "How does authentication work?" +- "What's the project structure?" +- "Show me the main components" +- "Where is the database logic?" +- Understanding code you haven't seen before + +## Workflow + +``` +1. READ gitnexus://repos → Discover indexed repos +2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness +3. gitnexus_query({query: ""}) → Find related execution flows +4. gitnexus_context({name: ""}) → Deep dive on specific symbol +5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow +``` + +> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] READ gitnexus://repo/{name}/context +- [ ] gitnexus_query for the concept you want to understand +- [ ] Review returned processes (execution flows) +- [ ] gitnexus_context on key symbols for callers/callees +- [ ] READ process resource for full execution traces +- [ ] Read source files for implementation details +``` + +## Resources + +| Resource | What you get | +| --------------------------------------- | ------------------------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | +| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | +| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | + +## Tools + +**gitnexus_query** — find execution flows related to a concept: + +``` +gitnexus_query({query: "payment processing"}) +→ Processes: CheckoutFlow, RefundFlow, WebhookHandler +→ Symbols grouped by flow with file locations +``` + +**gitnexus_context** — 360-degree view of a symbol: + +``` +gitnexus_context({name: "validateUser"}) +→ Incoming calls: loginHandler, apiMiddleware +→ Outgoing calls: checkToken, getUserById +→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) +``` + +## Example: "How does payment processing work?" + +``` +1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes +2. gitnexus_query({query: "payment processing"}) + → CheckoutFlow: processPayment → validateCard → chargeStripe + → RefundFlow: initiateRefund → calculateRefund → processRefund +3. gitnexus_context({name: "processPayment"}) + → Incoming: checkoutHandler, webhookHandler + → Outgoing: validateCard, chargeStripe, saveTransaction +4. Read src/payments/processor.ts for implementation details +``` diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md new file mode 100644 index 0000000..937ac73 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitnexus-guide +description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" +--- + +# GitNexus Guide + +Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. + +## Always Start Here + +For any task involving code understanding, debugging, impact analysis, or refactoring: + +1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness +2. **Match your task to a skill below** and **read that skill file** +3. **Follow the skill's workflow and checklist** + +> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. + +## Skills + +| Task | Skill to read | +| -------------------------------------------- | ------------------- | +| Understand architecture / "How does X work?" | `gitnexus-exploring` | +| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | +| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | +| Rename / extract / split / refactor | `gitnexus-refactoring` | +| Tools, resources, schema reference | `gitnexus-guide` (this file) | +| Index, status, clean, wiki CLI commands | `gitnexus-cli` | + +## Tools Reference + +| Tool | What it gives you | +| ---------------- | ------------------------------------------------------------------------ | +| `query` | Process-grouped code intelligence — execution flows related to a concept | +| `context` | 360-degree symbol view — categorized refs, processes it participates in | +| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `detect_changes` | Git-diff impact — what do your current changes affect | +| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | +| `list_repos` | Discover indexed repos | + +## Resources Reference + +Lightweight reads (~100-500 tokens) for navigation: + +| Resource | Content | +| ---------------------------------------------- | ----------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness check | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | +| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | + +## Graph Schema + +**Nodes:** File, Function, Class, Interface, Method, Community, Process +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) +RETURN caller.name, caller.filePath +``` diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md new file mode 100644 index 0000000..e19af28 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md @@ -0,0 +1,97 @@ +--- +name: gitnexus-impact-analysis +description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\"" +--- + +# Impact Analysis with GitNexus + +## When to Use + +- "Is it safe to change this function?" +- "What will break if I modify X?" +- "Show me the blast radius" +- "Who uses this code?" +- Before making non-trivial code changes +- Before committing — to understand what your changes affect + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this +2. READ gitnexus://repo/{name}/processes → Check affected execution flows +3. gitnexus_detect_changes() → Map current git changes to affected flows +4. Assess risk and report to user +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents +- [ ] Review d=1 items first (these WILL BREAK) +- [ ] Check high-confidence (>0.8) dependencies +- [ ] READ processes to check affected execution flows +- [ ] gitnexus_detect_changes() for pre-commit check +- [ ] Assess risk level and report to user +``` + +## Understanding Output + +| Depth | Risk Level | Meaning | +| ----- | ---------------- | ------------------------ | +| d=1 | **WILL BREAK** | Direct callers/importers | +| d=2 | LIKELY AFFECTED | Indirect dependencies | +| d=3 | MAY NEED TESTING | Transitive effects | + +## Risk Assessment + +| Affected | Risk | +| ------------------------------ | -------- | +| <5 symbols, few processes | LOW | +| 5-15 symbols, 2-5 processes | MEDIUM | +| >15 symbols or many processes | HIGH | +| Critical path (auth, payments) | CRITICAL | + +## Tools + +**gitnexus_impact** — the primary tool for symbol blast radius: + +``` +gitnexus_impact({ + target: "validateUser", + direction: "upstream", + minConfidence: 0.8, + maxDepth: 3 +}) + +→ d=1 (WILL BREAK): + - loginHandler (src/auth/login.ts:42) [CALLS, 100%] + - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%] + +→ d=2 (LIKELY AFFECTED): + - authRouter (src/routes/auth.ts:22) [CALLS, 95%] +``` + +**gitnexus_detect_changes** — git-diff based impact analysis: + +``` +gitnexus_detect_changes({scope: "staged"}) + +→ Changed: 5 symbols in 3 files +→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline +→ Risk: MEDIUM +``` + +## Example: "What breaks if I change validateUser?" + +``` +1. gitnexus_impact({target: "validateUser", direction: "upstream"}) + → d=1: loginHandler, apiMiddleware (WILL BREAK) + → d=2: authRouter, sessionManager (LIKELY AFFECTED) + +2. READ gitnexus://repo/my-app/processes + → LoginFlow and TokenRefresh touch validateUser + +3. Risk: 2 direct callers, 2 processes = MEDIUM +``` diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md new file mode 100644 index 0000000..f48cc01 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md @@ -0,0 +1,121 @@ +--- +name: gitnexus-refactoring +description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\"" +--- + +# Refactoring with GitNexus + +## When to Use + +- "Rename this function safely" +- "Extract this into a module" +- "Split this service" +- "Move this to a new file" +- Any task involving renaming, extracting, splitting, or restructuring code + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents +2. gitnexus_query({query: "X"}) → Find execution flows involving X +3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs +4. Plan update order: interfaces → implementations → callers → tests +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklists + +### Rename Symbol + +``` +- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) +- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits +- [ ] gitnexus_detect_changes() — verify only expected files changed +- [ ] Run tests for affected processes +``` + +### Extract Module + +``` +- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs +- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers +- [ ] Define new module interface +- [ ] Extract code, update imports +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +### Split Function/Service + +``` +- [ ] gitnexus_context({name: target}) — understand all callees +- [ ] Group callees by responsibility +- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update +- [ ] Create new functions/services +- [ ] Update callers +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +## Tools + +**gitnexus_rename** — automated multi-file rename: + +``` +gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +→ 12 edits across 8 files +→ 10 graph edits (high confidence), 2 ast_search edits (review) +→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +``` + +**gitnexus_impact** — map all dependents first: + +``` +gitnexus_impact({target: "validateUser", direction: "upstream"}) +→ d=1: loginHandler, apiMiddleware, testUtils +→ Affected Processes: LoginFlow, TokenRefresh +``` + +**gitnexus_detect_changes** — verify your changes after refactoring: + +``` +gitnexus_detect_changes({scope: "all"}) +→ Changed: 8 files, 12 symbols +→ Affected processes: LoginFlow, TokenRefresh +→ Risk: MEDIUM +``` + +**gitnexus_cypher** — custom reference queries: + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) +RETURN caller.name, caller.filePath ORDER BY caller.filePath +``` + +## Risk Rules + +| Risk Factor | Mitigation | +| ------------------- | ----------------------------------------- | +| Many callers (>5) | Use gitnexus_rename for automated updates | +| Cross-area refs | Use detect_changes after to verify scope | +| String/dynamic refs | gitnexus_query to find them | +| External/public API | Version and deprecate properly | + +## Example: Rename `validateUser` to `authenticateUser` + +``` +1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) + → 12 edits: 10 graph (safe), 2 ast_search (review) + → Files: validator.ts, login.ts, middleware.ts, config.json... + +2. Review ast_search edits (config.json: dynamic reference!) + +3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) + → Applied 12 edits across 8 files + +4. gitnexus_detect_changes({scope: "all"}) + → Affected: LoginFlow, TokenRefresh + → Risk: MEDIUM — run tests for these flows +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dcc4b76 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **36-character-flower** (2394 symbols, 4479 relationships, 203 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/36-character-flower/context` | Codebase overview, check index freshness | +| `gitnexus://repo/36-character-flower/clusters` | All functional areas | +| `gitnexus://repo/36-character-flower/processes` | All execution flows | +| `gitnexus://repo/36-character-flower/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dcc4b76 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,43 @@ + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **36-character-flower** (2394 symbols, 4479 relationships, 203 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/36-character-flower/context` | Codebase overview, check index freshness | +| `gitnexus://repo/36-character-flower/clusters` | All functional areas | +| `gitnexus://repo/36-character-flower/processes` | All execution flows | +| `gitnexus://repo/36-character-flower/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/figma/img_1.png b/figma/img_1.png new file mode 100644 index 0000000..ab7c98b Binary files /dev/null and b/figma/img_1.png differ diff --git a/src/assets/game/confirm-red-bg.webp b/src/assets/game/confirm-red-bg.webp deleted file mode 100644 index a02c6b5..0000000 Binary files a/src/assets/game/confirm-red-bg.webp and /dev/null differ diff --git a/src/assets/game/hosting-bg.webp b/src/assets/game/hosting-bg.webp new file mode 100644 index 0000000..1030cb7 Binary files /dev/null and b/src/assets/game/hosting-bg.webp differ diff --git a/src/assets/game/hosting-btn.webp b/src/assets/game/hosting-btn.webp new file mode 100644 index 0000000..0b253c7 Binary files /dev/null and b/src/assets/game/hosting-btn.webp differ diff --git a/src/assets/game/win-bg.webp b/src/assets/game/win-bg.webp new file mode 100644 index 0000000..5c168fa Binary files /dev/null and b/src/assets/game/win-bg.webp differ diff --git a/src/assets/game/win.webp b/src/assets/game/win.webp new file mode 100644 index 0000000..1b4955e Binary files /dev/null and b/src/assets/game/win.webp differ diff --git a/src/assets/system/refresh.webp b/src/assets/system/refresh.webp new file mode 100644 index 0000000..e6577be Binary files /dev/null and b/src/assets/system/refresh.webp differ diff --git a/src/constants/game.ts b/src/constants/game.ts index d144ed9..8ee5b93 100644 --- a/src/constants/game.ts +++ b/src/constants/game.ts @@ -176,6 +176,8 @@ export const GAME_SOCKET_TOPICS = { periodOpened: 'period.opened', // 本期派彩完成通知。用于结算阶段同步。 periodPayout: 'period.payout', + // 当前玩家中奖通知。用于前端显示大奖/小奖开奖 Lottie 动画与中奖金额。 + betWin: 'bet.win', // 当前玩家连胜与赔率信息。通常在结算后或演示帧刷新。 userStreak: 'user.streak', // 下注成功通知。仅当前用户可见,通常伴随扣款结果。 @@ -184,7 +186,7 @@ export const GAME_SOCKET_TOPICS = { walletChanged: 'wallet.changed', // 自动托管进度通知。包含托管开关、执行状态等。 autoSpinProgress: 'auto.spin.progress', - // 大奖命中通知。仅当本期存在中大奖用户时推送。 + // 全站大奖命中播报。用于公告栏展示中奖消息。 jackpotHit: 'jackpot.hit', // 后台实时页全量快照。仅 admin live 页面使用,当前 H5 前台不订阅。 adminLiveSnapshot: 'admin.live.snapshot', @@ -208,6 +210,7 @@ export const PLAYER_SOCKET_TOPICS = [ GAME_SOCKET_TOPICS.walletChanged, GAME_SOCKET_TOPICS.autoSpinProgress, GAME_SOCKET_TOPICS.jackpotHit, + GAME_SOCKET_TOPICS.betWin, ] as const /** @description 游戏实时连接延迟断开的等待时间,单位为毫秒。 */ diff --git a/src/features/auth/components/desktop-login-form-view.tsx b/src/features/auth/components/desktop-login-form-view.tsx index 890d366..cad6b40 100644 --- a/src/features/auth/components/desktop-login-form-view.tsx +++ b/src/features/auth/components/desktop-login-form-view.tsx @@ -1,11 +1,10 @@ -import { motion } from 'motion/react' +import { motion, useReducedMotion } from 'motion/react' import { useTranslation } from 'react-i18next' import loginBg from '@/assets/system/login-bg.webp' import { SmartBackground } from '@/components/smart-background.tsx' import { Input } from '@/components/ui/input.tsx' import { DesktopAuthFieldRow, - DesktopAuthFooterLinks, DesktopAuthInputError, DesktopAuthSubmitError, } from './desktop-auth-form-parts' @@ -18,6 +17,7 @@ interface DesktopLoginFormViewProps { isSubmitting: boolean onPasswordChange: (value: string) => void onSubmit: () => void + onSwitchToRegister: () => void onUsernameChange: (value: string) => void password: string submitError?: string | null @@ -29,12 +29,14 @@ export function DesktopLoginFormView({ isSubmitting, onPasswordChange, onSubmit, + onSwitchToRegister, onUsernameChange, password, submitError, username, }: DesktopLoginFormViewProps) { const { t } = useTranslation() + const prefersReducedMotion = useReducedMotion() return (
- - onUsernameChange(event.target.value)} - placeholder={t('auth.login.fields.username.placeholder')} - aria-invalid={Boolean(errors.username)} - className={'h-design-58 text-left'} - /> - - +
+
+
+
+
+
+
- - onPasswordChange(event.target.value)} - placeholder={t('auth.login.fields.password.placeholder')} - aria-invalid={Boolean(errors.password)} - className={'h-design-58 text-left'} - /> - - + + onUsernameChange(event.target.value)} + placeholder={t('auth.login.fields.username.placeholder')} + aria-describedby="desktop-login-username-error" + aria-invalid={Boolean(errors.username)} + className={ + 'h-design-58 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] text-left shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(129,239,243,0.05)]' + } + /> +
+
+ +
+
+
- - + + onPasswordChange(event.target.value)} + placeholder={t('auth.login.fields.password.placeholder')} + aria-describedby="desktop-login-password-error" + aria-invalid={Boolean(errors.password)} + className={ + 'h-design-58 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] text-left shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(129,239,243,0.05)]' + } + /> +
+
+ +
+
+
+ +
+ + +
+ +
+ +
+
- - {isSubmitting - ? t('auth.common.actions.submitting') - : t('auth.login.actions.submit')} - +
+
+ + + {isSubmitting + ? t('auth.common.actions.submitting') + : t('auth.login.actions.submit')} + + +
) } diff --git a/src/features/auth/components/desktop-login-form.tsx b/src/features/auth/components/desktop-login-form.tsx index 4c41b6a..686d918 100644 --- a/src/features/auth/components/desktop-login-form.tsx +++ b/src/features/auth/components/desktop-login-form.tsx @@ -1,4 +1,5 @@ import { useController } from 'react-hook-form' +import { useModalStore } from '@/store' import { useLoginForm } from '../hooks/use-login-form' import { DesktopLoginFormView } from './desktop-login-form-view' @@ -10,6 +11,7 @@ export function DesktopLoginForm({ onSuccess }: DesktopLoginFormProps) { const { form, isSubmitting, onSubmit, submitError } = useLoginForm({ onSuccess, }) + const setModalOpen = useModalStore((state) => state.setModalOpen) const usernameField = useController({ control: form.control, name: 'username', @@ -19,6 +21,11 @@ export function DesktopLoginForm({ onSuccess }: DesktopLoginFormProps) { name: 'password', }) + function handleSwitchToRegister() { + setModalOpen('desktopLogin', false) + setModalOpen('desktopRegister', true) + } + return ( diff --git a/src/features/auth/components/desktop-register-form-view.tsx b/src/features/auth/components/desktop-register-form-view.tsx index d00091b..3523ffd 100644 --- a/src/features/auth/components/desktop-register-form-view.tsx +++ b/src/features/auth/components/desktop-register-form-view.tsx @@ -1,11 +1,10 @@ -import { motion } from 'motion/react' +import { motion, useReducedMotion } from 'motion/react' import { useTranslation } from 'react-i18next' import loginBg from '@/assets/system/login-bg.webp' import { SmartBackground } from '@/components/smart-background.tsx' import { Input } from '@/components/ui/input.tsx' import { DesktopAuthFieldRow, - DesktopAuthFooterLinks, DesktopAuthInputError, DesktopAuthSubmitError, } from './desktop-auth-form-parts' @@ -23,6 +22,7 @@ interface DesktopRegisterFormViewProps { onInviteCodeChange: (value: string) => void onPasswordChange: (value: string) => void onSubmit: () => void + onSwitchToLogin: () => void onUsernameChange: (value: string) => void password: string confirmPassword: string @@ -39,12 +39,14 @@ export function DesktopRegisterFormView({ onInviteCodeChange, onPasswordChange, onSubmit, + onSwitchToLogin, onUsernameChange, password, submitError, username, }: DesktopRegisterFormViewProps) { const { t } = useTranslation() + const prefersReducedMotion = useReducedMotion() return (
- - onUsernameChange(event.target.value)} - placeholder={t('auth.register.fields.username.placeholder')} - aria-invalid={Boolean(errors.username)} - className={'h-design-58 text-left'} - /> - - +
+
+
+
+
+
+
- - onPasswordChange(event.target.value)} - placeholder={t('auth.register.fields.password.placeholder')} - aria-invalid={Boolean(errors.password)} - className={'h-design-58 text-left'} - /> - - + + onUsernameChange(event.target.value)} + placeholder={t('auth.register.fields.username.placeholder')} + aria-describedby="desktop-register-username-error" + aria-invalid={Boolean(errors.username)} + className={ + 'h-design-58 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] text-left shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(129,239,243,0.05)]' + } + /> +
+
+ +
+
+
- - onConfirmPasswordChange(event.target.value)} - placeholder={t('auth.register.fields.confirmPassword.placeholder')} - aria-invalid={Boolean(errors.confirmPassword)} - className={'h-design-58 text-left'} - /> - - + + onPasswordChange(event.target.value)} + placeholder={t('auth.register.fields.password.placeholder')} + aria-describedby="desktop-register-password-error" + aria-invalid={Boolean(errors.password)} + className={ + 'h-design-58 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] text-left shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(129,239,243,0.05)]' + } + /> +
+
+ +
+
+
- - onInviteCodeChange(event.target.value)} - placeholder={t('auth.register.fields.inviteCode.placeholder')} - aria-invalid={Boolean(errors.inviteCode)} - className={'h-design-58 max-w-design-520 text-left'} - /> - - + + onConfirmPasswordChange(event.target.value)} + placeholder={t( + 'auth.register.fields.confirmPassword.placeholder', + )} + aria-describedby="desktop-register-confirm-password-error" + aria-invalid={Boolean(errors.confirmPassword)} + className={ + 'h-design-58 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] text-left shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(129,239,243,0.05)]' + } + /> +
+
+ +
+
+
- - + + onInviteCodeChange(event.target.value)} + placeholder={t('auth.register.fields.inviteCode.placeholder')} + aria-describedby="desktop-register-invite-code-error" + aria-invalid={Boolean(errors.inviteCode)} + className={ + 'h-design-58 max-w-design-520 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] text-left shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(129,239,243,0.05)]' + } + /> +
+
+ +
+
+
+ +
+ + + +
+ +
+ +
+
- - {isSubmitting - ? t('auth.common.actions.submitting') - : t('auth.register.actions.submit')} - +
+
+ + + {isSubmitting + ? t('auth.common.actions.submitting') + : t('auth.register.actions.submit')} + + +
) } diff --git a/src/features/auth/components/desktop-register-form.tsx b/src/features/auth/components/desktop-register-form.tsx index 4917574..d4ac73e 100644 --- a/src/features/auth/components/desktop-register-form.tsx +++ b/src/features/auth/components/desktop-register-form.tsx @@ -1,4 +1,5 @@ import { useController } from 'react-hook-form' +import { useModalStore } from '@/store' import { useRegisterForm } from '../hooks/use-register-form' import { DesktopRegisterFormView } from './desktop-register-form-view' @@ -10,6 +11,7 @@ export function DesktopRegisterForm({ onSuccess }: DesktopRegisterFormProps) { const { form, isSubmitting, onSubmit, submitError } = useRegisterForm({ onSuccess, }) + const setModalOpen = useModalStore((state) => state.setModalOpen) const usernameField = useController({ control: form.control, name: 'username', @@ -27,6 +29,11 @@ export function DesktopRegisterForm({ onSuccess }: DesktopRegisterFormProps) { name: 'inviteCode', }) + function handleSwitchToLogin() { + setModalOpen('desktopRegister', false) + setModalOpen('desktopLogin', true) + } + return ( diff --git a/src/features/game/api/types.ts b/src/features/game/api/types.ts index 57ca0af..d58bdaa 100644 --- a/src/features/game/api/types.ts +++ b/src/features/game/api/types.ts @@ -213,6 +213,57 @@ export interface GamePeriodTickDto { status: GamePeriodStatus } +export interface JackpotHitItemDto { + period_no: string + result_number: number + total_win: string + user_id: number +} + +export interface JackpotHitEventDataDto { + hits: JackpotHitItemDto[] + period_id: number | null + period_no: string + result_number: number | null + server_time: number +} + +export interface JackpotHitEventDto { + data: JackpotHitEventDataDto + event: 'jackpot.hit' + server_time: number + topic?: 'jackpot.hit' +} + +export interface BetWinItemDto { + bet_id: number + win_amount: string +} + +export interface BetWinEventDataDto { + balance_after?: string + bets: BetWinItemDto[] + current_streak?: number + is_jackpot: boolean + is_win: boolean + odds_factor?: number + payout_pending_review: boolean + period_id?: number + period_no: string + result_number: number | null + server_time?: number + streak_level?: number + total_win: string + user_id?: number +} + +export interface BetWinEventDto { + data: BetWinEventDataDto + event: 'bet.win' + server_time: number + topic?: 'bet.win' +} + export interface GameBetOrderDto { bet_amount: string create_time: number diff --git a/src/features/game/components/desktop/desktop-animal.tsx b/src/features/game/components/desktop/desktop-animal.tsx index d1a90cb..d22a653 100644 --- a/src/features/game/components/desktop/desktop-animal.tsx +++ b/src/features/game/components/desktop/desktop-animal.tsx @@ -1,22 +1,20 @@ import { TriangleAlert } from 'lucide-react' -import { motion } from 'motion/react' +import { motion, useReducedMotion } from 'motion/react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import animalBorderImage from '@/assets/game/animal-border.webp' import enStopImage from '@/assets/game/en-stop.webp' +import hostingBg from '@/assets/game/hosting-bg.webp' +import hostingBtn from '@/assets/game/hosting-btn.webp' import zhStopImage from '@/assets/game/zh-stop.webp' import diamondIcon from '@/assets/system/diamond.webp' -import { LottiePlayer } from '@/components/lottie-player' +import refreshIcon from '@/assets/system/refresh.webp' +import { SmartBackground } from '@/components/smart-background.tsx' import { SmartImage } from '@/components/smart-image' import { useAnimalVm } from '@/features/game/hooks/use-animal-vm' import { cn } from '@/lib/utils' import { useAuthStore } from '@/store/auth' -import { useGameRoundStore } from '@/store/game' - -const revealBorderPath = new URL( - '../../../../assets/lottie/test.json', - import.meta.url, -).href +import { useGameAutoHostingStore, useGameRoundStore } from '@/store/game' const animalModules = import.meta.glob('../../../../assets/animal/*.webp', { eager: true, @@ -35,6 +33,11 @@ const animalImageList = Object.entries(animalModules) .filter((item) => item.id > 0) .sort((left, right) => left.id - right.id) +const SETTLEMENT_REVEAL_RANDOM_DURATION_MS = 4_000 +const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000 +const SETTLEMENT_REVEAL_MIN_STEP_MS = 90 +const SETTLEMENT_REVEAL_MAX_STEP_MS = 480 + function getRandomAnimalId(ids: number[], currentId: number | null) { if (ids.length === 0) { return null @@ -53,6 +56,17 @@ function getRandomAnimalId(ids: number[], currentId: number | null) { return nextId } +function getSettlementRevealStepDelay(progress: number) { + const clampedProgress = Math.min(Math.max(progress, 0), 1) + const easedProgress = clampedProgress ** 1.65 + + return ( + SETTLEMENT_REVEAL_MIN_STEP_MS + + (SETTLEMENT_REVEAL_MAX_STEP_MS - SETTLEMENT_REVEAL_MIN_STEP_MS) * + easedProgress + ) +} + interface DesktopAnimalProps { className?: string itemClassName?: string @@ -67,6 +81,7 @@ export function DesktopAnimal({ onSelect, }: DesktopAnimalProps) { const { i18n, t } = useTranslation() + const prefersReducedMotion = useReducedMotion() const animalIds = useMemo(() => animalImageList.map((item) => item.id), []) const containerRef = useRef(null) const cellRefs = useRef(new Map()) @@ -77,6 +92,7 @@ export function DesktopAnimal({ top: number width: number } | null>(null) + const [isRevealHoldingResult, setIsRevealHoldingResult] = useState(false) const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase) const revealWinningCellId = useGameRoundStore( (state) => state.revealAnimation.winningCellId, @@ -86,6 +102,11 @@ export function DesktopAnimal({ const lastBetPeriodNo = useAuthStore( (state) => state.currentUser?.lastBetPeriodNo, ) + const completedAutoHostingRounds = useGameAutoHostingStore( + (state) => state.completedRounds, + ) + const hostingFlag = useGameAutoHostingStore((state) => state.isHosting) + const stopHosting = useGameAutoHostingStore((state) => state.stopHosting) const finishRevealAnimation = useGameRoundStore( (state) => state.finishRevealAnimation, ) @@ -99,8 +120,10 @@ export function DesktopAnimal({ selectionByCell, showStandbyState, } = useAnimalVm(animalIds, onSelect) + const isRevealRunning = - revealPhase === 'spinning' || revealPhase === 'stopping' + revealPhase === 'spinning' || + (revealPhase === 'stopping' && !isRevealHoldingResult) const isRevealResult = revealPhase === 'result' const hasSubmittedCurrentRound = roundPhase === 'betting' && Boolean(roundId) && lastBetPeriodNo === roundId @@ -115,15 +138,18 @@ export function DesktopAnimal({ useEffect(() => { if (revealPhase === 'idle') { setRevealCellId(null) + setIsRevealHoldingResult(false) return } if (revealPhase === 'result') { setRevealCellId(revealWinningCellId) + setIsRevealHoldingResult(false) return } if (revealPhase === 'spinning') { + setIsRevealHoldingResult(false) setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId)) const intervalId = window.setInterval(() => { @@ -136,35 +162,40 @@ export function DesktopAnimal({ } if (revealWinningCellId === null) { + setIsRevealHoldingResult(false) return } - let elapsedMs = 0 - const timeoutIds: number[] = [] + const startedAt = performance.now() + let timeoutId = 0 + setIsRevealHoldingResult(false) - for (let index = 0; index < 14; index += 1) { - elapsedMs += 42 + index * 13 - timeoutIds.push( - window.setTimeout(() => { - setRevealCellId((currentId) => - getRandomAnimalId(animalIds, currentId), - ) - }, elapsedMs), - ) + const step = () => { + const elapsedMs = performance.now() - startedAt + + if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) { + setRevealCellId(revealWinningCellId) + setIsRevealHoldingResult(true) + timeoutId = window.setTimeout(() => { + finishRevealAnimation() + }, SETTLEMENT_REVEAL_RESULT_HOLD_MS) + return + } + + setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId)) + + const progress = elapsedMs / SETTLEMENT_REVEAL_RANDOM_DURATION_MS + const nextDelayMs = getSettlementRevealStepDelay(progress) + const remainingMs = SETTLEMENT_REVEAL_RANDOM_DURATION_MS - elapsedMs + + timeoutId = window.setTimeout(step, Math.min(nextDelayMs, remainingMs)) } - elapsedMs += 160 - timeoutIds.push( - window.setTimeout(() => { - setRevealCellId(revealWinningCellId) - finishRevealAnimation() - }, elapsedMs), - ) + setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId)) + timeoutId = window.setTimeout(step, SETTLEMENT_REVEAL_MIN_STEP_MS) return () => { - for (const timeoutId of timeoutIds) { - window.clearTimeout(timeoutId) - } + window.clearTimeout(timeoutId) } }, [animalIds, finishRevealAnimation, revealPhase, revealWinningCellId]) @@ -214,7 +245,9 @@ export function DesktopAnimal({ const selectionMeta = selectionByCell[item.id] const hasPlacedSelection = Boolean(selectionMeta) const isMarqueeActive = showStandbyState && item.id === marqueeId - const isRevealWinner = isRevealResult && revealWinningCellId === item.id + const isRevealWinner = + (isRevealResult || isRevealHoldingResult) && + revealWinningCellId === item.id const warningType = cellWarning?.cellId === item.id ? cellWarning.type : null const showCellWarning = warningType !== null @@ -394,18 +427,27 @@ export function DesktopAnimal({ aria-hidden="true" className="pointer-events-none absolute z-40 transition-[height,transform,width] duration-75 ease-linear" style={{ - height: revealFrame.height + 16, - transform: `translate(${revealFrame.left - 8}px, ${revealFrame.top - 8}px)`, - width: revealFrame.width + 16, + height: revealFrame.height, + transform: `translate(${revealFrame.left}px, ${revealFrame.top}px)`, + width: revealFrame.width, }} > - +
+
) : null} @@ -426,6 +468,42 @@ export function DesktopAnimal({
) : null} + {hostingFlag ? ( +
+ +
+
+ +
+ {t('game.autoSpin.runningRounds', { + count: completedAutoHostingRounds, + })} +
+
+ + {t('game.actions.stopAuto')} + +
+
+
+ ) : null} + {showStandbyState ? (
diff --git a/src/features/game/components/desktop/desktop-reward-overlay.tsx b/src/features/game/components/desktop/desktop-reward-overlay.tsx new file mode 100644 index 0000000..8660128 --- /dev/null +++ b/src/features/game/components/desktop/desktop-reward-overlay.tsx @@ -0,0 +1,244 @@ +import { useEffect, useMemo, useState } from 'react' +import winLogo from '@/assets/game/win.webp' +import winBg from '@/assets/game/win-bg.webp' +import { FullscreenLottieOverlay } from '@/components/fullscreen-lottie-overlay.tsx' +import type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts' +import { SmartBackground } from '@/components/smart-background.tsx' +import { SmartImage } from '@/components/smart-image.tsx' +import { REWARD_OVERLAY_DURATION_MS } from '@/constants' +import { cn } from '@/lib/utils.ts' +import { useGameRoundStore } from '@/store' + +const smallRewardPath = new URL( + '../../../../assets/lottie/pc-small-reward.json', + import.meta.url, +).href +const bigRewardPath = new URL( + '../../../../assets/lottie/pc-big-reward.json', + import.meta.url, +).href +const REWARD_OVERLAY_FADE_OUT_MS = 300 +const REWARD_CHILDREN_FADE_IN_MS = 2_000 +const REWARD_CHILDREN_VISIBLE_MS = 1_000 + +type RewardChildrenStage = 'hidden' | 'visible' | 'exiting' + +function easeOutCubic(progress: number) { + return 1 - (1 - progress) ** 3 +} + +function getAmountMeta(amount: string | null) { + if (!amount) { + return null + } + + const normalizedAmount = amount.replace(/,/g, '') + const numericAmount = Number(normalizedAmount) + + if (!Number.isFinite(numericAmount)) { + return null + } + + const fractionDigits = normalizedAmount.includes('.') + ? (normalizedAmount.split('.')[1]?.length ?? 0) + : 0 + + return { + fractionDigits, + numericAmount, + } +} + +function formatRewardAmount(value: number, fractionDigits: number) { + return value.toLocaleString('en-US', { + maximumFractionDigits: fractionDigits, + minimumFractionDigits: fractionDigits, + }) +} + +function DesktopRewardOverlay() { + const rewardType = useGameRoundStore( + (state) => state.revealAnimation.rewardType, + ) + const rewardAmount = useGameRoundStore( + (state) => state.revealAnimation.rewardAmount, + ) + const revealKey = useGameRoundStore( + (state) => state.revealAnimation.revealKey, + ) + const roundId = useGameRoundStore((state) => state.revealAnimation.roundId) + const clearRewardAnimation = useGameRoundStore( + (state) => state.clearRewardAnimation, + ) + const [isFadingOut, setIsFadingOut] = useState(false) + const [childrenStage, setChildrenStage] = + useState('hidden') + const [displayRewardAmount, setDisplayRewardAmount] = useState('0') + const rewardAmountMeta = useMemo( + () => getAmountMeta(rewardAmount), + [rewardAmount], + ) + const source = useMemo(() => { + if (rewardType === 'small') { + return { + id: 'pc-small-reward', + path: smallRewardPath, + loop: false, + autoplay: true, + } + } + + if (rewardType === 'big') { + return { + id: 'pc-big-reward', + path: bigRewardPath, + loop: false, + autoplay: true, + } + } + + return null + }, [rewardType]) + + useEffect(() => { + if (rewardType === 'none') { + return + } + + setIsFadingOut(false) + + const fadeTimerId = window.setTimeout(() => { + setIsFadingOut(true) + }, REWARD_OVERLAY_DURATION_MS) + const clearTimerId = window.setTimeout(() => { + clearRewardAnimation() + }, REWARD_OVERLAY_DURATION_MS + REWARD_OVERLAY_FADE_OUT_MS) + + return () => { + window.clearTimeout(fadeTimerId) + window.clearTimeout(clearTimerId) + } + }, [clearRewardAnimation, rewardType]) + + const shouldRenderOverlay = rewardType !== 'none' + const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}` + const childTimelineKey = shouldRenderOverlay ? overlayAnimationKey : 'closed' + + useEffect(() => { + if (childTimelineKey === 'closed') { + setChildrenStage('hidden') + setDisplayRewardAmount('0') + return + } + + setChildrenStage('hidden') + setDisplayRewardAmount( + rewardAmountMeta + ? formatRewardAmount(0, rewardAmountMeta.fractionDigits) + : (rewardAmount ?? '0'), + ) + + const enterFrameId = window.requestAnimationFrame(() => { + setChildrenStage('visible') + }) + const exitTimerId = window.setTimeout(() => { + setChildrenStage('exiting') + }, REWARD_CHILDREN_FADE_IN_MS + REWARD_CHILDREN_VISIBLE_MS) + + return () => { + window.cancelAnimationFrame(enterFrameId) + window.clearTimeout(exitTimerId) + } + }, [childTimelineKey, rewardAmount, rewardAmountMeta]) + + useEffect(() => { + if (childTimelineKey === 'closed') { + return + } + + if (!rewardAmountMeta) { + setDisplayRewardAmount(rewardAmount ?? '0') + return + } + + let animationFrameId = 0 + const startedAt = performance.now() + + const syncRewardAmount = (now: number) => { + const progress = Math.min( + (now - startedAt) / REWARD_CHILDREN_FADE_IN_MS, + 1, + ) + setDisplayRewardAmount( + progress >= 1 + ? formatRewardAmount( + rewardAmountMeta.numericAmount, + rewardAmountMeta.fractionDigits, + ) + : formatRewardAmount( + rewardAmountMeta.numericAmount * easeOutCubic(progress), + rewardAmountMeta.fractionDigits, + ), + ) + + if (progress < 1) { + animationFrameId = window.requestAnimationFrame(syncRewardAmount) + } + } + + animationFrameId = window.requestAnimationFrame(syncRewardAmount) + + return () => { + window.cancelAnimationFrame(animationFrameId) + } + }, [childTimelineKey, rewardAmount, rewardAmountMeta]) + + return ( + +
+ + +
+ {displayRewardAmount} +
+
+
+
+ ) +} + +export default DesktopRewardOverlay diff --git a/src/features/game/components/desktop/desktop-status.tsx b/src/features/game/components/desktop/desktop-status.tsx index 81090a6..a4fa50e 100644 --- a/src/features/game/components/desktop/desktop-status.tsx +++ b/src/features/game/components/desktop/desktop-status.tsx @@ -6,7 +6,6 @@ import fire from '@/assets/system/fire.webp' import lock from '@/assets/system/lock.webp' import statusCenter from '@/assets/system/status-center.webp' import statusLine from '@/assets/system/status-line.webp' -import streakBg from '@/assets/system/streak.webp' import { LottiePlayer } from '@/components/lottie-player.tsx' import { SmartBackground } from '@/components/smart-background.tsx' import { SmartImage } from '@/components/smart-image.tsx' diff --git a/src/features/game/components/desktop/desktop-title.tsx b/src/features/game/components/desktop/desktop-title.tsx index 09b8a72..9398863 100644 --- a/src/features/game/components/desktop/desktop-title.tsx +++ b/src/features/game/components/desktop/desktop-title.tsx @@ -1,18 +1,53 @@ import { useTranslation } from 'react-i18next' import broadcast from '@/assets/system/broadcast.webp' import { SmartImage } from '@/components/smart-image.tsx' +import { useGameSessionStore } from '@/store/game' + export function DesktopTitle() { const { t } = useTranslation() + const jackpotBroadcasts = useGameSessionStore( + (state) => state.jackpotBroadcasts, + ) + const titles = + jackpotBroadcasts.length > 0 + ? jackpotBroadcasts.map((broadcast) => ({ + id: broadcast.id, + message: broadcast.message, + })) + : [{ id: 'empty', message: '' }] + const marqueeTitles = + jackpotBroadcasts.length > 0 + ? [ + ...titles.map((title) => ({ ...title, id: `${title.id}:first` })), + ...titles.map((title) => ({ ...title, id: `${title.id}:second` })), + ] + : titles return ( -
+
-
- {t('gameDesktop.title.announcement')} +
+ {t('gameDesktop.title.announcement')}: +
+
+
0 ? 'desktop-title-vertical-marquee' : '' + } + > + {marqueeTitles.map((title) => ( +
+ {title.message} +
+ ))} +
) diff --git a/src/features/game/components/desktop/desktop-withdraw.tsx b/src/features/game/components/desktop/desktop-withdraw.tsx index 299cb88..5a0f6c1 100644 --- a/src/features/game/components/desktop/desktop-withdraw.tsx +++ b/src/features/game/components/desktop/desktop-withdraw.tsx @@ -654,7 +654,7 @@ function DesktopWithdraw() { > {withdrawSubmitMutation.isPending ? t('commonUi.action.submitting') - : `${t('gameDesktop.withdraw.confirm')} ${t('gameDesktop.withdraw.withdrawal')}`} + : `${t('gameDesktop.withdraw.confirm')}${t('gameDesktop.withdraw.withdrawal')}`}
diff --git a/src/features/game/components/shared/entry-notice-gate-modal.tsx b/src/features/game/components/shared/entry-notice-gate-modal.tsx index d4b6c97..40f15da 100644 --- a/src/features/game/components/shared/entry-notice-gate-modal.tsx +++ b/src/features/game/components/shared/entry-notice-gate-modal.tsx @@ -162,8 +162,8 @@ export function EntryNoticeGateModal() { )}
-
-