refactor: 重构中奖和推送大奖事件,中奖过度动画,和开奖动画
83
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal file
@@ -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 <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | 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
|
||||
89
.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
Normal file
@@ -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: "<error or symptom>"}) → Find related execution flows
|
||||
2. gitnexus_context({name: "<suspect>"}) → 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
|
||||
```
|
||||
78
.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
Normal file
@@ -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: "<what you want to understand>"}) → Find related execution flows
|
||||
4. gitnexus_context({name: "<symbol>"}) → 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
|
||||
```
|
||||
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal file
@@ -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
|
||||
```
|
||||
97
.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
Normal file
@@ -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
|
||||
```
|
||||
121
.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
Normal file
@@ -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
|
||||
```
|
||||
43
AGENTS.md
Normal file
@@ -0,0 +1,43 @@
|
||||
<!-- gitnexus:start -->
|
||||
# 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` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
43
CLAUDE.md
Normal file
@@ -0,0 +1,43 @@
|
||||
<!-- gitnexus:start -->
|
||||
# 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` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
BIN
figma/img_1.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
BIN
src/assets/game/hosting-bg.webp
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
src/assets/game/hosting-btn.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/game/win-bg.webp
Normal file
|
After Width: | Height: | Size: 404 KiB |
BIN
src/assets/game/win.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src/assets/system/refresh.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
@@ -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 游戏实时连接延迟断开的等待时间,单位为毫秒。 */
|
||||
|
||||
@@ -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 (
|
||||
<form
|
||||
@@ -43,65 +45,118 @@ export function DesktopLoginFormView({
|
||||
onSubmit()
|
||||
}}
|
||||
className={
|
||||
'flex flex-col items-center justify-between gap-design-20 px-design-20'
|
||||
'relative isolate flex h-full flex-col justify-between gap-design-24 px-design-30 py-design-20 pt-design-10'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'h-design-375 flex flex-col gap-design-30 w-full bg-[#060B0F]/50 p-design-50'
|
||||
'relative w-full overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-[#214B53] bg-[linear-gradient(180deg,rgba(7,21,27,0.94)_0%,rgba(5,15,20,0.82)_100%)] shadow-[0_0_calc(var(--design-unit)*26)_rgba(6,112,126,0.14),inset_0_0_0_calc(var(--design-unit)*1)_rgba(120,222,227,0.08)] backdrop-blur-[calc(var(--design-unit)*10)]'
|
||||
}
|
||||
>
|
||||
<DesktopAuthFieldRow label={t('auth.login.fields.username.label')}>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(event) => onUsernameChange(event.target.value)}
|
||||
placeholder={t('auth.login.fields.username.placeholder')}
|
||||
aria-invalid={Boolean(errors.username)}
|
||||
className={'h-design-58 text-left'}
|
||||
/>
|
||||
<DesktopAuthInputError
|
||||
message={errors.username ? t(errors.username) : undefined}
|
||||
/>
|
||||
</DesktopAuthFieldRow>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(93,211,218,0.16),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(63,109,137,0.22),transparent_34%)]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-design-120 bg-[linear-gradient(180deg,rgba(87,196,201,0.12),transparent)]" />
|
||||
<div className="relative flex h-design-375 flex-col gap-design-24 p-design-50">
|
||||
<div className="flex items-center gap-design-14">
|
||||
<div className="h-design-10 w-design-10 rounded-full bg-[#6EE4E6] shadow-[0_0_calc(var(--design-unit)*12)_rgba(110,228,230,0.75)]" />
|
||||
<div className="h-px flex-1 bg-[linear-gradient(90deg,rgba(110,228,230,0.5),rgba(110,228,230,0))]" />
|
||||
</div>
|
||||
|
||||
<DesktopAuthFieldRow label={t('auth.login.fields.password.label')}>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => onPasswordChange(event.target.value)}
|
||||
placeholder={t('auth.login.fields.password.placeholder')}
|
||||
aria-invalid={Boolean(errors.password)}
|
||||
className={'h-design-58 text-left'}
|
||||
/>
|
||||
<DesktopAuthInputError
|
||||
message={errors.password ? t(errors.password) : undefined}
|
||||
/>
|
||||
</DesktopAuthFieldRow>
|
||||
<DesktopAuthFieldRow label={t('auth.login.fields.username.label')}>
|
||||
<Input
|
||||
id="desktop-login-username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
value={username}
|
||||
onChange={(event) => 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)]'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
id="desktop-login-username-error"
|
||||
className="relative h-design-30 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0">
|
||||
<DesktopAuthInputError
|
||||
message={errors.username ? t(errors.username) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DesktopAuthFieldRow>
|
||||
|
||||
<DesktopAuthSubmitError
|
||||
message={submitError ? t(submitError) : undefined}
|
||||
/>
|
||||
<DesktopAuthFooterLinks
|
||||
primaryLabel={t('auth.login.footer.registerAccount')}
|
||||
secondaryLabel={t('auth.login.footer.forgotPassword')}
|
||||
/>
|
||||
<DesktopAuthFieldRow label={t('auth.login.fields.password.label')}>
|
||||
<Input
|
||||
id="desktop-login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => 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)]'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
id="desktop-login-password-error"
|
||||
className="relative h-design-30 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0">
|
||||
<DesktopAuthInputError
|
||||
message={errors.password ? t(errors.password) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DesktopAuthFieldRow>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-design-18">
|
||||
<DesktopAuthSubmitError
|
||||
message={submitError ? t(submitError) : undefined}
|
||||
/>
|
||||
<motion.div
|
||||
whileTap={prefersReducedMotion ? undefined : { scale: 0.98 }}
|
||||
className="flex items-center justify-center gap-design-12 text-center text-design-20 text-[#6DB5B9]"
|
||||
>
|
||||
<div className="h-px w-design-90 bg-[linear-gradient(90deg,rgba(109,181,185,0),rgba(109,181,185,0.7))]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToRegister}
|
||||
className="cursor-pointer underline underline-offset-[calc(var(--design-unit)*4)] transition-colors duration-200 ease-out hover:text-[#90DBDE] focus-visible:outline-none focus-visible:text-[#90DBDE]"
|
||||
>
|
||||
{t('auth.login.footer.registerAccount')}
|
||||
</button>
|
||||
<div className="h-px w-design-90 bg-[linear-gradient(90deg,rgba(109,181,185,0.7),rgba(109,181,185,0))]" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SmartBackground
|
||||
as={motion.button}
|
||||
type="submit"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
src={loginBg}
|
||||
size="100% 100%"
|
||||
className={
|
||||
'w-design-390 h-design-110 flex items-center justify-center text-design-32 modal-title-glow font-bold cursor-pointer disabled:pointer-events-none disabled:opacity-60'
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? t('auth.common.actions.submitting')
|
||||
: t('auth.login.actions.submit')}
|
||||
</SmartBackground>
|
||||
<div className="relative flex w-full justify-center pt-design-4">
|
||||
<div className="pointer-events-none absolute inset-x-[24%] top-1/2 h-design-40 -translate-y-1/2 rounded-full bg-[rgba(76,213,216,0.22)] blur-[calc(var(--design-unit)*22)]" />
|
||||
<SmartBackground
|
||||
as={motion.button}
|
||||
type="submit"
|
||||
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
|
||||
src={loginBg}
|
||||
size="100% 100%"
|
||||
className={
|
||||
'relative z-10 flex h-design-110 w-design-390 cursor-pointer items-center justify-center overflow-hidden text-design-32 font-bold text-[#F2FFFF] duration-200 ease-out hover:brightness-110 disabled:pointer-events-none disabled:opacity-60'
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className="modal-title-glow text-design-24">
|
||||
{isSubmitting
|
||||
? t('auth.common.actions.submitting')
|
||||
: t('auth.login.actions.submit')}
|
||||
</span>
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<DesktopLoginFormView
|
||||
username={usernameField.field.value ?? ''}
|
||||
@@ -30,6 +37,7 @@ export function DesktopLoginForm({ onSuccess }: DesktopLoginFormProps) {
|
||||
isSubmitting={isSubmitting}
|
||||
onPasswordChange={passwordField.field.onChange}
|
||||
onSubmit={onSubmit}
|
||||
onSwitchToRegister={handleSwitchToRegister}
|
||||
onUsernameChange={usernameField.field.onChange}
|
||||
submitError={submitError}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<form
|
||||
@@ -52,98 +54,185 @@ export function DesktopRegisterFormView({
|
||||
event.preventDefault()
|
||||
onSubmit()
|
||||
}}
|
||||
className={'flex flex-col items-center justify-between px-design-20'}
|
||||
className={
|
||||
'relative isolate flex flex-col px-design-30 mt-design-10 box-border h-design-700 !overflow-hidden'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'h-design-490 flex flex-col gap-design-26 w-full bg-[#060B0F]/50 p-design-50'
|
||||
'relative w-full overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-[#214B53] bg-[linear-gradient(180deg,rgba(7,21,27,0.94)_0%,rgba(5,15,20,0.82)_100%)] shadow-[0_0_calc(var(--design-unit)*26)_rgba(6,112,126,0.14),inset_0_0_0_calc(var(--design-unit)*1)_rgba(120,222,227,0.08)] backdrop-blur-[calc(var(--design-unit)*10)]'
|
||||
}
|
||||
>
|
||||
<DesktopAuthFieldRow label={t('auth.register.fields.username.label')}>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(event) => onUsernameChange(event.target.value)}
|
||||
placeholder={t('auth.register.fields.username.placeholder')}
|
||||
aria-invalid={Boolean(errors.username)}
|
||||
className={'h-design-58 text-left'}
|
||||
/>
|
||||
<DesktopAuthInputError
|
||||
message={errors.username ? t(errors.username) : undefined}
|
||||
/>
|
||||
</DesktopAuthFieldRow>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(93,211,218,0.16),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(63,109,137,0.22),transparent_34%)]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-design-140 bg-[linear-gradient(180deg,rgba(87,196,201,0.12),transparent)]" />
|
||||
<div className="relative flex flex-col gap-design-20 p-design-50">
|
||||
<div className="flex items-center gap-design-14">
|
||||
<div className="h-design-10 w-design-10 rounded-full bg-[#6EE4E6] shadow-[0_0_calc(var(--design-unit)*12)_rgba(110,228,230,0.75)]" />
|
||||
<div className="h-px flex-1 bg-[linear-gradient(90deg,rgba(110,228,230,0.5),rgba(110,228,230,0))]" />
|
||||
</div>
|
||||
|
||||
<DesktopAuthFieldRow label={t('auth.register.fields.password.label')}>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => onPasswordChange(event.target.value)}
|
||||
placeholder={t('auth.register.fields.password.placeholder')}
|
||||
aria-invalid={Boolean(errors.password)}
|
||||
className={'h-design-58 text-left'}
|
||||
/>
|
||||
<DesktopAuthInputError
|
||||
message={errors.password ? t(errors.password) : undefined}
|
||||
/>
|
||||
</DesktopAuthFieldRow>
|
||||
<DesktopAuthFieldRow label={t('auth.register.fields.username.label')}>
|
||||
<Input
|
||||
id="desktop-register-username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
value={username}
|
||||
onChange={(event) => 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)]'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
id="desktop-register-username-error"
|
||||
className="relative h-design-30 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0">
|
||||
<DesktopAuthInputError
|
||||
message={errors.username ? t(errors.username) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DesktopAuthFieldRow>
|
||||
|
||||
<DesktopAuthFieldRow
|
||||
label={t('auth.register.fields.confirmPassword.label')}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => onConfirmPasswordChange(event.target.value)}
|
||||
placeholder={t('auth.register.fields.confirmPassword.placeholder')}
|
||||
aria-invalid={Boolean(errors.confirmPassword)}
|
||||
className={'h-design-58 text-left'}
|
||||
/>
|
||||
<DesktopAuthInputError
|
||||
message={
|
||||
errors.confirmPassword ? t(errors.confirmPassword) : undefined
|
||||
}
|
||||
/>
|
||||
</DesktopAuthFieldRow>
|
||||
<DesktopAuthFieldRow label={t('auth.register.fields.password.label')}>
|
||||
<Input
|
||||
id="desktop-register-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => 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)]'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
id="desktop-register-password-error"
|
||||
className="relative h-design-30 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0">
|
||||
<DesktopAuthInputError
|
||||
message={errors.password ? t(errors.password) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DesktopAuthFieldRow>
|
||||
|
||||
<DesktopAuthFieldRow
|
||||
label={t('auth.register.fields.inviteCode.label')}
|
||||
labelClassName="whitespace-nowrap"
|
||||
>
|
||||
<Input
|
||||
value={inviteCode}
|
||||
onChange={(event) => 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'}
|
||||
/>
|
||||
<DesktopAuthInputError
|
||||
message={errors.inviteCode ? t(errors.inviteCode) : undefined}
|
||||
/>
|
||||
</DesktopAuthFieldRow>
|
||||
<DesktopAuthFieldRow
|
||||
label={t('auth.register.fields.confirmPassword.label')}
|
||||
>
|
||||
<Input
|
||||
id="desktop-register-confirm-password"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => 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)]'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
id="desktop-register-confirm-password-error"
|
||||
className="relative h-design-30 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0">
|
||||
<DesktopAuthInputError
|
||||
message={
|
||||
errors.confirmPassword
|
||||
? t(errors.confirmPassword)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DesktopAuthFieldRow>
|
||||
|
||||
<DesktopAuthSubmitError
|
||||
message={submitError ? t(submitError) : undefined}
|
||||
/>
|
||||
<DesktopAuthFooterLinks
|
||||
primaryLabel={t('auth.register.footer.alreadyHaveAccount')}
|
||||
secondaryLabel={t('auth.register.footer.needHelp')}
|
||||
/>
|
||||
<DesktopAuthFieldRow
|
||||
label={t('auth.register.fields.inviteCode.label')}
|
||||
labelClassName="whitespace-nowrap"
|
||||
>
|
||||
<Input
|
||||
id="desktop-register-invite-code"
|
||||
name="inviteCode"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={inviteCode}
|
||||
onChange={(event) => 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)]'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
id="desktop-register-invite-code-error"
|
||||
className="relative h-design-30 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0">
|
||||
<DesktopAuthInputError
|
||||
message={errors.inviteCode ? t(errors.inviteCode) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DesktopAuthFieldRow>
|
||||
|
||||
<div className="mt-auto flex flex-col">
|
||||
<DesktopAuthSubmitError
|
||||
message={submitError ? t(submitError) : undefined}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
whileTap={prefersReducedMotion ? undefined : { scale: 0.98 }}
|
||||
className="flex items-center justify-center gap-design-12 text-center text-design-20 text-[#6DB5B9]"
|
||||
>
|
||||
<div className="h-px w-design-90 bg-[linear-gradient(90deg,rgba(109,181,185,0),rgba(109,181,185,0.7))]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToLogin}
|
||||
className="cursor-pointer underline underline-offset-[calc(var(--design-unit)*4)] transition-colors duration-200 ease-out hover:text-[#90DBDE] focus-visible:outline-none focus-visible:text-[#90DBDE]"
|
||||
>
|
||||
{t('auth.register.footer.alreadyHaveAccount')}
|
||||
</button>
|
||||
<div className="h-px w-design-90 bg-[linear-gradient(90deg,rgba(109,181,185,0.7),rgba(109,181,185,0))]" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SmartBackground
|
||||
as={motion.button}
|
||||
type="submit"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
src={loginBg}
|
||||
size="100% 100%"
|
||||
className={
|
||||
'w-design-390 h-design-110 flex items-center justify-center text-design-32 modal-title-glow font-bold cursor-pointer disabled:pointer-events-none disabled:opacity-60'
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? t('auth.common.actions.submitting')
|
||||
: t('auth.register.actions.submit')}
|
||||
</SmartBackground>
|
||||
<div className="relative flex w-full justify-center">
|
||||
<div className="pointer-events-none absolute inset-x-[24%] top-1/2 h-design-40 -translate-y-1/2 rounded-full bg-[rgba(76,213,216,0.22)] blur-[calc(var(--design-unit)*22)]" />
|
||||
<SmartBackground
|
||||
as={motion.button}
|
||||
type="submit"
|
||||
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
|
||||
src={loginBg}
|
||||
size="100% 100%"
|
||||
className={
|
||||
'relative z-10 flex h-design-110 w-design-390 cursor-pointer items-center justify-center overflow-hidden text-design-32 font-bold text-[#F2FFFF] duration-200 ease-out hover:brightness-110 disabled:pointer-events-none disabled:opacity-60'
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className="modal-title-glow text-design-24">
|
||||
{isSubmitting
|
||||
? t('auth.common.actions.submitting')
|
||||
: t('auth.register.actions.submit')}
|
||||
</span>
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<DesktopRegisterFormView
|
||||
username={usernameField.field.value ?? ''}
|
||||
@@ -44,6 +51,7 @@ export function DesktopRegisterForm({ onSuccess }: DesktopRegisterFormProps) {
|
||||
onInviteCodeChange={inviteCodeField.field.onChange}
|
||||
onPasswordChange={passwordField.field.onChange}
|
||||
onSubmit={onSubmit}
|
||||
onSwitchToLogin={handleSwitchToLogin}
|
||||
onUsernameChange={usernameField.field.onChange}
|
||||
submitError={submitError}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<HTMLElement | null>(null)
|
||||
const cellRefs = useRef(new Map<number, HTMLButtonElement>())
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
<LottiePlayer
|
||||
path={revealBorderPath}
|
||||
renderer="svg"
|
||||
loop
|
||||
autoplay
|
||||
speed={1.8}
|
||||
className="h-full w-full scale-[1.18] [&>svg]:h-full [&>svg]:w-full"
|
||||
<div
|
||||
className="gold-reveal-glow rounded-[calc(var(--design-unit)*16)]"
|
||||
style={
|
||||
prefersReducedMotion
|
||||
? {
|
||||
animation: 'none',
|
||||
opacity: 0.36,
|
||||
transform: 'scale(1)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="gold-reveal-static-border rounded-[calc(var(--design-unit)*16)]" />
|
||||
<div
|
||||
className="gold-reveal-shell rounded-[calc(var(--design-unit)*16)]"
|
||||
style={prefersReducedMotion ? { animation: 'none' } : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -426,6 +468,42 @@ export function DesktopAnimal({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hostingFlag ? (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]">
|
||||
<SmartBackground
|
||||
src={hostingBg}
|
||||
className="h-design-350 w-design-930 flex flex-col items-center justify-center"
|
||||
>
|
||||
<div className={'flex flex-col gap-design-40 items-center'}>
|
||||
<div className={'flex items-center gap-design-20'}>
|
||||
<SmartImage
|
||||
src={refreshIcon}
|
||||
alt="refreshIcon"
|
||||
priority
|
||||
showSkeleton={false}
|
||||
className="h-design-40 w-design-40"
|
||||
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
|
||||
/>
|
||||
<div className={'text-design-20 text-[#ffffff] font-bold'}>
|
||||
{t('game.autoSpin.runningRounds', {
|
||||
count: completedAutoHostingRounds,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={stopHosting}
|
||||
src={hostingBtn}
|
||||
className="h-design-80 w-design-170 flex cursor-pointer flex-col items-center justify-center transition-transform hover:-translate-y-[1px] active:translate-y-0"
|
||||
>
|
||||
{t('game.actions.stopAuto')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showStandbyState ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -468,7 +468,7 @@ export function DesktopControl() {
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'relative z-10 flex h-full w-design-260 shrink-0 items-center justify-center bg-center bg-no-repeat text-design-32 font-bold',
|
||||
'relative z-10 flex h-full w-design-260 shrink-0 items-center justify-center bg-center bg-no-repeat text-design-24 font-bold',
|
||||
isConfirmClickable ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
Mail,
|
||||
Maximize,
|
||||
Minimize,
|
||||
UserKey,
|
||||
UserRoundPlus,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
@@ -223,7 +225,7 @@ export function DesktopHeader() {
|
||||
}
|
||||
onClick={onOpenLogin}
|
||||
>
|
||||
<CircleAlert color={'#57B8BF'} size={16} />
|
||||
<UserKey color={'#57B8BF'} size={16} />
|
||||
<div>{t('gameDesktop.header.login')}</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -233,7 +235,7 @@ export function DesktopHeader() {
|
||||
}
|
||||
onClick={onOpenRegister}
|
||||
>
|
||||
<CircleAlert color={'#57B8BF'} size={16} />
|
||||
<UserRoundPlus color={'#57B8BF'} size={16} />
|
||||
<div>{t('gameDesktop.header.register')}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
244
src/features/game/components/desktop/desktop-reward-overlay.tsx
Normal file
@@ -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<RewardChildrenStage>('hidden')
|
||||
const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
|
||||
const rewardAmountMeta = useMemo(
|
||||
() => getAmountMeta(rewardAmount),
|
||||
[rewardAmount],
|
||||
)
|
||||
const source = useMemo<FullscreenLottieSource | null>(() => {
|
||||
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 (
|
||||
<FullscreenLottieOverlay
|
||||
open={shouldRenderOverlay}
|
||||
source={source}
|
||||
animationKey={overlayAnimationKey}
|
||||
zIndex={120}
|
||||
loop={false}
|
||||
autoplay
|
||||
lockBodyScroll={!isFadingOut}
|
||||
backdropClassName={cn(
|
||||
'bg-black/70 transition-opacity duration-300',
|
||||
isFadingOut && 'pointer-events-none opacity-0',
|
||||
)}
|
||||
viewportClassName="px-0 py-0"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center pb-design-220 transition-[opacity,transform,filter] ease-out',
|
||||
childrenStage === 'visible' || childrenStage === 'exiting'
|
||||
? 'duration-[2000ms]'
|
||||
: 'duration-0',
|
||||
childrenStage === 'visible' &&
|
||||
'translate-y-0 scale-100 opacity-75 blur-none',
|
||||
childrenStage === 'hidden' &&
|
||||
'translate-y-[calc(var(--design-unit)*18)] scale-[0.96] opacity-0 blur-[calc(var(--design-unit)*2)]',
|
||||
childrenStage === 'exiting' &&
|
||||
'translate-y-[calc(var(--design-unit)*-14)] scale-[0.97] opacity-0 blur-[calc(var(--design-unit)*1.5)]',
|
||||
)}
|
||||
>
|
||||
<SmartBackground
|
||||
className="flex h-design-175 w-design-900 items-center justify-center gap-design-24 pb-design-50"
|
||||
src={winBg}
|
||||
size="contain"
|
||||
>
|
||||
<SmartImage
|
||||
className="h-design-50 w-design-225 drop-shadow-[0_0_calc(var(--design-unit)*10)_rgba(255,218,122,0.72)]"
|
||||
alt="win"
|
||||
src={winLogo}
|
||||
/>
|
||||
<div className="h-design-50 min-w-design-150 animate-bounce text-center font-sans text-design-56 leading-[calc(var(--design-unit)*50)] font-black tabular-nums text-[#FFE89A] [animation-duration:900ms] [-webkit-text-stroke:calc(var(--design-unit)*1)_#8A3A08] [text-shadow:0_0_calc(var(--design-unit)*6)_rgba(255,236,154,0.95),0_calc(var(--design-unit)*3)_0_#7A2F05,0_0_calc(var(--design-unit)*18)_rgba(255,151,15,0.72)]">
|
||||
{displayRewardAmount}
|
||||
</div>
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</FullscreenLottieOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopRewardOverlay
|
||||
@@ -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'
|
||||
|
||||
@@ -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 (
|
||||
<section className="common-neon-inset text-design-16 w-full flex h-design-65 items-center gap-design-10 !px-design-20 ">
|
||||
<section className="common-neon-inset text-design-16 w-full flex h-design-65 items-center gap-design-10 !px-design-20 overflow-hidden">
|
||||
<SmartImage
|
||||
className={'w-design-24 h-design-24'}
|
||||
alt={'broadcast'}
|
||||
src={broadcast}
|
||||
/>
|
||||
<div className={'!text-[#FF970F]'}>
|
||||
{t('gameDesktop.title.announcement')}
|
||||
<div className="shrink-0 !text-[#FF970F]">
|
||||
{t('gameDesktop.title.announcement')}:
|
||||
</div>
|
||||
<div className="relative h-design-28 min-w-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
className={
|
||||
jackpotBroadcasts.length > 0 ? 'desktop-title-vertical-marquee' : ''
|
||||
}
|
||||
>
|
||||
{marqueeTitles.map((title) => (
|
||||
<div
|
||||
className="flex h-design-28 items-center whitespace-nowrap !text-[#FF970F]"
|
||||
key={title.id}
|
||||
>
|
||||
{title.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -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')}`}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,8 +162,8 @@ export function EntryNoticeGateModal() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-center gap-design-28">
|
||||
<label className="inline-flex cursor-pointer items-center gap-design-12 text-design-20 text-[#C4F2F7]">
|
||||
<div className="flex shrink-0 flex-col items-center justify-center gap-design-20">
|
||||
<label className="inline-flex cursor-pointer items-center justify-center gap-design-12 text-design-20 text-[#C4F2F7]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasAgreed}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
|
||||
import {
|
||||
DesktopHeader,
|
||||
FullscreenLottieOverlay,
|
||||
type FullscreenLottieSource,
|
||||
} from '@/features/game/components'
|
||||
import { DesktopHeader } from '@/features/game/components'
|
||||
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
|
||||
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
|
||||
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
||||
import DesktopRewardOverlay from '@/features/game/components/desktop/desktop-reward-overlay.tsx'
|
||||
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
||||
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
|
||||
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
|
||||
import DesktopLanguageModal from '@/features/game/modal/desktop/desktop-language-modal.tsx'
|
||||
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
|
||||
@@ -18,79 +14,10 @@ import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register
|
||||
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
|
||||
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
|
||||
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
||||
import { useGameRoundStore } from '@/store/game'
|
||||
|
||||
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
|
||||
|
||||
function DesktopRewardOverlay() {
|
||||
const rewardType = useGameRoundStore(
|
||||
(state) => state.revealAnimation.rewardType,
|
||||
)
|
||||
const revealKey = useGameRoundStore(
|
||||
(state) => state.revealAnimation.revealKey,
|
||||
)
|
||||
const roundId = useGameRoundStore((state) => state.revealAnimation.roundId)
|
||||
const clearRewardAnimation = useGameRoundStore(
|
||||
(state) => state.clearRewardAnimation,
|
||||
)
|
||||
const source = useMemo<FullscreenLottieSource | null>(() => {
|
||||
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
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
clearRewardAnimation()
|
||||
}, REWARD_OVERLAY_DURATION_MS)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
}, [clearRewardAnimation, rewardType])
|
||||
|
||||
return (
|
||||
<FullscreenLottieOverlay
|
||||
open={rewardType !== 'none'}
|
||||
source={source}
|
||||
animationKey={`${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`}
|
||||
zIndex={120}
|
||||
loop={false}
|
||||
autoplay
|
||||
backdropClassName="bg-black/70"
|
||||
viewportClassName="px-0 py-0"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PcEntry() {
|
||||
useAutoHostingRunner()
|
||||
|
||||
return (
|
||||
<>
|
||||
<DesktopHeader />
|
||||
@@ -142,6 +69,7 @@ export function PcEntry() {
|
||||
<DesktopProceduresModal />
|
||||
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
|
||||
<DesktopWithdrawTopupModal />
|
||||
{/* 大奖/小奖动画展示 */}
|
||||
<DesktopRewardOverlay />
|
||||
</>
|
||||
)
|
||||
|
||||
252
src/features/game/hooks/use-auto-hosting-runner.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { placeGameBet } from '@/features/game'
|
||||
import type { BetSelection } from '@/features/game/shared'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGameAutoHostingStore, useGameRoundStore } from '@/store/game'
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function createIdempotencyKey() {
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.randomUUID === 'function'
|
||||
) {
|
||||
return `auto-bet-${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
return `auto-bet-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function toBetId(chipId: string) {
|
||||
const match = chipId.match(/^chip-(\d+)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const betId = Number(match[1])
|
||||
|
||||
return Number.isInteger(betId) && betId >= 1 && betId <= 6 ? betId : null
|
||||
}
|
||||
|
||||
function groupSelections(selections: BetSelection[]) {
|
||||
return selections.reduce<Map<string, { betId: number; numbers: number[] }>>(
|
||||
(accumulator, selection) => {
|
||||
const betId = toBetId(selection.chipId)
|
||||
|
||||
if (betId === null) {
|
||||
return accumulator
|
||||
}
|
||||
|
||||
const groupKey = String(betId)
|
||||
const current = accumulator.get(groupKey)
|
||||
|
||||
if (current) {
|
||||
current.numbers.push(selection.cellId)
|
||||
return accumulator
|
||||
}
|
||||
|
||||
accumulator.set(groupKey, {
|
||||
betId,
|
||||
numbers: [selection.cellId],
|
||||
})
|
||||
|
||||
return accumulator
|
||||
},
|
||||
new Map(),
|
||||
)
|
||||
}
|
||||
|
||||
export function useAutoHostingRunner() {
|
||||
const { t } = useTranslation()
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||
const balanceAfterBet = useGameAutoHostingStore(
|
||||
(state) => state.balanceAfterBet,
|
||||
)
|
||||
const isHosting = useGameAutoHostingStore((state) => state.isHosting)
|
||||
const lastSubmittedRoundId = useGameAutoHostingStore(
|
||||
(state) => state.lastSubmittedRoundId,
|
||||
)
|
||||
const rules = useGameAutoHostingStore((state) => state.rules)
|
||||
const selections = useGameAutoHostingStore((state) => state.selections)
|
||||
const markRoundSubmitted = useGameAutoHostingStore(
|
||||
(state) => state.markRoundSubmitted,
|
||||
)
|
||||
const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const previousJackpotRef = useRef(currentUser?.isJackpot === true)
|
||||
|
||||
useEffect(() => {
|
||||
const isJackpot = currentUser?.isJackpot === true
|
||||
|
||||
if (!isHosting) {
|
||||
previousJackpotRef.current = isJackpot
|
||||
return
|
||||
}
|
||||
|
||||
const balance = parseBalance(currentUser?.coin)
|
||||
|
||||
if (
|
||||
rules.stopIfBalanceBelow.enabled &&
|
||||
balance < rules.stopIfBalanceBelow.amount
|
||||
) {
|
||||
stopHosting()
|
||||
notify.warning(t('commonUi.toast.autoHostingStoppedBalance'))
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
rules.stopIfSingleWinAbove.enabled &&
|
||||
balanceAfterBet !== null &&
|
||||
balance - balanceAfterBet > rules.stopIfSingleWinAbove.amount
|
||||
) {
|
||||
stopHosting()
|
||||
notify.success(t('commonUi.toast.autoHostingStoppedWin'))
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
rules.stopOnJackpot &&
|
||||
isJackpot &&
|
||||
previousJackpotRef.current === false
|
||||
) {
|
||||
stopHosting()
|
||||
notify.success(t('commonUi.toast.autoHostingStoppedJackpot'))
|
||||
return
|
||||
}
|
||||
|
||||
previousJackpotRef.current = isJackpot
|
||||
}, [
|
||||
balanceAfterBet,
|
||||
currentUser?.coin,
|
||||
currentUser?.isJackpot,
|
||||
isHosting,
|
||||
rules,
|
||||
stopHosting,
|
||||
t,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isHosting ||
|
||||
isSubmitting ||
|
||||
authStatus !== 'authenticated' ||
|
||||
!currentUser ||
|
||||
round.phase !== 'betting' ||
|
||||
!round.id ||
|
||||
lastSubmittedRoundId === round.id
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const groupedSelections = groupSelections(selections)
|
||||
|
||||
if (groupedSelections.size === 0) {
|
||||
stopHosting()
|
||||
notify.warning(t('commonUi.toast.autoHostingStopped'))
|
||||
return
|
||||
}
|
||||
|
||||
const totalBetAmount = selections.reduce(
|
||||
(total, selection) => total + selection.amount,
|
||||
0,
|
||||
)
|
||||
const balance = parseBalance(currentUser.coin)
|
||||
|
||||
if (totalBetAmount > balance) {
|
||||
stopHosting()
|
||||
notify.warning(t('commonUi.toast.autoHostingStoppedBalance'))
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const submitAutoBet = async () => {
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
let latestBalance = currentUser.coin ?? '0'
|
||||
|
||||
for (const group of groupedSelections.values()) {
|
||||
const uniqueNumbers = [...new Set(group.numbers)].sort(
|
||||
(left, right) => left - right,
|
||||
)
|
||||
const result = await placeGameBet({
|
||||
bet_id: group.betId,
|
||||
idempotency_key: createIdempotencyKey(),
|
||||
numbers: uniqueNumbers.join(','),
|
||||
period_no: round.id,
|
||||
})
|
||||
|
||||
if (result.status !== 'accepted') {
|
||||
throw new Error(t('commonUi.toast.betRejected'))
|
||||
}
|
||||
|
||||
latestBalance = result.balance_after
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentUser({
|
||||
...currentUser,
|
||||
coin: latestBalance,
|
||||
lastBetPeriodNo: round.id,
|
||||
})
|
||||
markRoundSubmitted(round.id, parseBalance(latestBalance))
|
||||
clearSelections()
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
stopHosting()
|
||||
notify.error(t('commonUi.toast.autoHostingSubmitFailed'), {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void submitAutoBet()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
authStatus,
|
||||
clearSelections,
|
||||
currentUser,
|
||||
isHosting,
|
||||
isSubmitting,
|
||||
lastSubmittedRoundId,
|
||||
markRoundSubmitted,
|
||||
round.id,
|
||||
round.phase,
|
||||
selections,
|
||||
setCurrentUser,
|
||||
stopHosting,
|
||||
t,
|
||||
])
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { notify } from '@/lib/notify'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
import {
|
||||
selectSelectionTotal,
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
@@ -73,6 +74,7 @@ export function useGameControlVm() {
|
||||
const setRecentSuccessfulSelections = useGameRoundStore(
|
||||
(state) => state.setRecentSuccessfulSelections,
|
||||
)
|
||||
const isAutoHosting = useGameAutoHostingStore((state) => state.isHosting)
|
||||
const selectChip = useGameRoundStore((state) => state.selectChip)
|
||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||
const connectionStatus = useGameSessionStore(
|
||||
@@ -114,13 +116,14 @@ export function useGameControlVm() {
|
||||
const hasSubmittedCurrentRound =
|
||||
Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id
|
||||
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
|
||||
const confirmState: ConfirmState = isSubmitting
|
||||
? 'submitting'
|
||||
: !hasSelections
|
||||
? 'idle'
|
||||
: hasInsufficientBalance
|
||||
? 'insufficient'
|
||||
: 'ready'
|
||||
const confirmState: ConfirmState =
|
||||
isSubmitting || isAutoHosting
|
||||
? 'submitting'
|
||||
: !hasSelections
|
||||
? 'idle'
|
||||
: hasInsufficientBalance
|
||||
? 'insufficient'
|
||||
: 'ready'
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (confirmState === 'submitting' || !hasSelections) {
|
||||
@@ -258,13 +261,27 @@ export function useGameControlVm() {
|
||||
])
|
||||
|
||||
const handleOpenAutoSetting = useCallback(() => {
|
||||
if (!hasSelections) {
|
||||
notify.warning(t('commonUi.toast.selectNumbersBeforeAutoHosting'))
|
||||
return
|
||||
}
|
||||
|
||||
setModalOpen('desktopAutoSetting', true)
|
||||
}, [setModalOpen])
|
||||
}, [hasSelections, setModalOpen, t])
|
||||
|
||||
return {
|
||||
acceptingBets: round.phase === 'betting' && !hasSubmittedCurrentRound,
|
||||
actionsEnabled: hasEnteredGame && !hasSubmittedCurrentRound,
|
||||
canClear: selections.length > 0 && !hasSubmittedCurrentRound,
|
||||
acceptingBets:
|
||||
round.phase === 'betting' && !hasSubmittedCurrentRound && !isAutoHosting,
|
||||
actionsEnabled:
|
||||
hasEnteredGame &&
|
||||
round.phase === 'betting' &&
|
||||
!hasSubmittedCurrentRound &&
|
||||
!isAutoHosting,
|
||||
canClear:
|
||||
selections.length > 0 &&
|
||||
round.phase === 'betting' &&
|
||||
!hasSubmittedCurrentRound &&
|
||||
!isAutoHosting,
|
||||
confirmLabel:
|
||||
confirmState === 'idle'
|
||||
? t('gameDesktop.control.selectNumbers')
|
||||
@@ -274,7 +291,7 @@ export function useGameControlVm() {
|
||||
? t('gameDesktop.control.submitting')
|
||||
: t('gameDesktop.control.confirm'),
|
||||
confirmState,
|
||||
isConfirmClickable: confirmState === 'ready',
|
||||
isConfirmClickable: confirmState === 'ready' && !isAutoHosting,
|
||||
onChipSelect: selectChip,
|
||||
onConfirm: handleConfirm,
|
||||
onClearSelections: clearSelections,
|
||||
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
import { getGameLobbyInit, normalizePeriodTickRound } from '../api/game-api'
|
||||
import type { GamePeriodTickDto } from '../api/types'
|
||||
import type {
|
||||
BetWinEventDataDto,
|
||||
GamePeriodTickDto,
|
||||
JackpotHitEventDataDto,
|
||||
JackpotHitItemDto,
|
||||
} from '../api/types'
|
||||
|
||||
type UserStreakMessageData = {
|
||||
currentStreak: number
|
||||
@@ -29,6 +34,10 @@ type PeriodEventData = {
|
||||
resultNumber: number | null
|
||||
}
|
||||
|
||||
type WalletChangedData = {
|
||||
coin: string
|
||||
}
|
||||
|
||||
let sharedSocketClient: GameSocketClient | null = null
|
||||
let sharedSocketKey: string | null = null
|
||||
let sharedSocketDisconnectTimerId: number | null = null
|
||||
@@ -55,6 +64,34 @@ function toOptionalNumber(value: unknown) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toOptionalString(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toOptionalBoolean(value: unknown) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value === 1 || value === '1' || value === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (value === 0 || value === '0' || value === 'false') {
|
||||
return false
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getNestedRecord(
|
||||
value: unknown,
|
||||
key: string,
|
||||
@@ -101,10 +138,11 @@ function extractServerTime(message: GameSocketMessage) {
|
||||
function extractUserStreakMessageData(
|
||||
message: GameSocketMessage,
|
||||
): UserStreakMessageData | null {
|
||||
const direct = getNestedRecord(message, 'user_snapshot')
|
||||
const data = getNestedRecord(message, 'data')
|
||||
const direct = getNestedRecord(message, 'user_snapshot')
|
||||
const nested = getNestedRecord(data, 'user_snapshot')
|
||||
const source = direct ?? nested ?? data
|
||||
const source =
|
||||
data && 'current_streak' in data ? data : (nested ?? direct ?? data)
|
||||
|
||||
if (!source || typeof source.current_streak !== 'number') {
|
||||
return null
|
||||
@@ -184,23 +222,163 @@ function extractPeriodEventData(
|
||||
}
|
||||
}
|
||||
|
||||
function extractWalletCoin(message: GameSocketMessage) {
|
||||
function extractWalletChangedData(
|
||||
message: GameSocketMessage,
|
||||
): WalletChangedData | null {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
const source = data ?? (message as Record<string, unknown>)
|
||||
const coin = source.coin ?? source.balance ?? source.balance_after
|
||||
const normalizedCoin =
|
||||
typeof coin === 'string'
|
||||
? coin
|
||||
: typeof coin === 'number' && Number.isFinite(coin)
|
||||
? String(coin)
|
||||
: null
|
||||
|
||||
if (typeof coin === 'string') {
|
||||
return coin
|
||||
if (normalizedCoin === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return typeof coin === 'number' && Number.isFinite(coin) ? String(coin) : null
|
||||
return {
|
||||
coin: normalizedCoin,
|
||||
}
|
||||
}
|
||||
|
||||
function extractJackpotStatus(message: GameSocketMessage) {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
const source = data ?? (message as Record<string, unknown>)
|
||||
function extractJackpotHitItem(value: unknown): JackpotHitItemDto | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
return typeof source.is_jackpot === 'boolean' ? source.is_jackpot : true
|
||||
const source = value as Record<string, unknown>
|
||||
|
||||
if (
|
||||
typeof source.user_id !== 'number' ||
|
||||
typeof source.period_no !== 'string' ||
|
||||
typeof source.total_win !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resultNumber = toOptionalNumber(source.result_number)
|
||||
|
||||
if (typeof resultNumber !== 'number' || !Number.isInteger(resultNumber)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
period_no: source.period_no,
|
||||
result_number: resultNumber,
|
||||
total_win: source.total_win,
|
||||
user_id: source.user_id,
|
||||
}
|
||||
}
|
||||
|
||||
function extractJackpotHitData(
|
||||
message: GameSocketMessage,
|
||||
): JackpotHitEventDataDto | null {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
|
||||
if (!data || typeof data.period_no !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const serverTime = toOptionalNumber(data.server_time)
|
||||
|
||||
if (typeof serverTime !== 'number') {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourceHits = Array.isArray(data.hits) ? data.hits : [data]
|
||||
const hits = sourceHits
|
||||
.map((item) => extractJackpotHitItem(item))
|
||||
.filter((item): item is JackpotHitItemDto => item !== null)
|
||||
const resultNumber = toOptionalNumber(data.result_number)
|
||||
|
||||
return {
|
||||
hits,
|
||||
period_id:
|
||||
typeof data.period_id === 'number' && Number.isInteger(data.period_id)
|
||||
? data.period_id
|
||||
: null,
|
||||
period_no: data.period_no,
|
||||
result_number:
|
||||
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
||||
? resultNumber
|
||||
: null,
|
||||
server_time: serverTime,
|
||||
}
|
||||
}
|
||||
|
||||
function extractBetWinData(
|
||||
message: GameSocketMessage,
|
||||
): BetWinEventDataDto | null {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const root = message as Record<string, unknown>
|
||||
const userId = toOptionalNumber(data.user_id)
|
||||
const periodId = toOptionalNumber(data.period_id)
|
||||
const resultNumber = toOptionalNumber(data.result_number)
|
||||
const totalWin = toOptionalString(data.total_win)
|
||||
const balanceAfter = toOptionalString(data.balance_after)
|
||||
const serverTime = toOptionalNumber(data.server_time ?? root.server_time)
|
||||
const currentStreak = toOptionalNumber(data.current_streak)
|
||||
const streakLevel = toOptionalNumber(data.streak_level)
|
||||
const oddsFactor = toOptionalNumber(data.odds_factor)
|
||||
const isJackpot = toOptionalBoolean(data.is_jackpot)
|
||||
|
||||
if (
|
||||
typeof totalWin !== 'string' ||
|
||||
typeof data.period_no !== 'string' ||
|
||||
typeof isJackpot !== 'boolean'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
balance_after: balanceAfter,
|
||||
bets: Array.isArray(data.bets)
|
||||
? data.bets
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const bet = item as Record<string, unknown>
|
||||
const betId = toOptionalNumber(bet.bet_id)
|
||||
const winAmount = toOptionalString(bet.win_amount)
|
||||
|
||||
return typeof betId === 'number' && typeof winAmount === 'string'
|
||||
? {
|
||||
bet_id: betId,
|
||||
win_amount: winAmount,
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter(
|
||||
(item): item is BetWinEventDataDto['bets'][number] => item !== null,
|
||||
)
|
||||
: [],
|
||||
current_streak: currentStreak,
|
||||
is_jackpot: isJackpot,
|
||||
is_win: toOptionalBoolean(data.is_win) ?? true,
|
||||
odds_factor: typeof oddsFactor === 'number' ? oddsFactor : undefined,
|
||||
payout_pending_review:
|
||||
toOptionalBoolean(data.payout_pending_review) ?? false,
|
||||
period_id: periodId,
|
||||
period_no: data.period_no,
|
||||
result_number:
|
||||
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
|
||||
? resultNumber
|
||||
: null,
|
||||
server_time: serverTime,
|
||||
streak_level: typeof streakLevel === 'number' ? streakLevel : undefined,
|
||||
total_win: totalWin,
|
||||
user_id: userId,
|
||||
}
|
||||
}
|
||||
|
||||
function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
|
||||
@@ -271,7 +449,7 @@ function applyPeriodMessage(
|
||||
revealingAt: round.revealingAt,
|
||||
settledAt: round.settledAt,
|
||||
startedAt: round.startedAt,
|
||||
winningCellId: round.winningCellId,
|
||||
winningCellId: previousRound.winningCellId,
|
||||
})
|
||||
useGameSessionStore.getState().syncDashboard({
|
||||
countdownMs: period.countdown * 1000,
|
||||
@@ -323,9 +501,6 @@ function applyPeriodOpenedMessage(
|
||||
const openedAt = toIsoFromUnixSeconds(
|
||||
period.openTime ?? serverTime ?? Math.floor(Date.now() / 1000),
|
||||
)
|
||||
const hasSmallReward = roundState.selections.some(
|
||||
(selection) => selection.cellId === period.resultNumber,
|
||||
)
|
||||
const revealKey = `${period.periodNo}:${period.resultNumber}`
|
||||
|
||||
roundState.syncRound({
|
||||
@@ -335,7 +510,6 @@ function applyPeriodOpenedMessage(
|
||||
winningCellId: period.resultNumber,
|
||||
})
|
||||
useGameRoundStore.getState().prepareRevealAnimation({
|
||||
hasSmallReward,
|
||||
revealKey,
|
||||
roundId: period.periodNo,
|
||||
winningCellId: period.resultNumber,
|
||||
@@ -354,10 +528,11 @@ function applyPeriodPayoutMessage(
|
||||
const roundState = useGameRoundStore.getState()
|
||||
const revealKey = `${period.periodNo}:${period.resultNumber}`
|
||||
|
||||
roundState.syncRound({
|
||||
id: period.periodNo,
|
||||
winningCellId: period.resultNumber,
|
||||
})
|
||||
roundState.prepareRevealAnimation({
|
||||
hasSmallReward: roundState.selections.some(
|
||||
(selection) => selection.cellId === period.resultNumber,
|
||||
),
|
||||
revealKey,
|
||||
roundId: period.periodNo,
|
||||
winningCellId: period.resultNumber,
|
||||
@@ -381,29 +556,56 @@ function applyUserStreakMessage(message: GameSocketMessage) {
|
||||
useAuthStore.getState().setCurrentUser({
|
||||
...currentUser,
|
||||
currentStreak: streakData.currentStreak,
|
||||
oddsFactor: streakData.oddsFactor,
|
||||
streakLevel: streakData.streakLevel,
|
||||
oddsFactor: streakData.oddsFactor ?? currentUser.oddsFactor,
|
||||
streakLevel: streakData.streakLevel ?? currentUser.streakLevel,
|
||||
})
|
||||
}
|
||||
|
||||
function applyWalletChangedMessage(message: GameSocketMessage) {
|
||||
const coin = extractWalletCoin(message)
|
||||
const walletData = extractWalletChangedData(message)
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
|
||||
if (coin === null || !currentUser) {
|
||||
if (!walletData || !currentUser) {
|
||||
return
|
||||
}
|
||||
|
||||
useAuthStore.getState().setCurrentUser({
|
||||
...currentUser,
|
||||
coin,
|
||||
coin: walletData.coin,
|
||||
})
|
||||
}
|
||||
|
||||
function applyJackpotHitMessage(message: GameSocketMessage) {
|
||||
const jackpotHitData = extractJackpotHitData(message)
|
||||
|
||||
if (jackpotHitData?.hits.length) {
|
||||
useGameSessionStore.getState().pushJackpotBroadcasts(
|
||||
jackpotHitData.hits.map((hit) => ({
|
||||
id: `${jackpotHitData.period_no}:${hit.result_number}:${hit.user_id}:${hit.total_win}`,
|
||||
message: `恭喜${hit.user_id} 用户中奖,获得${hit.total_win}`,
|
||||
periodNo: hit.period_no,
|
||||
totalWin: hit.total_win,
|
||||
userId: hit.user_id,
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function applyBetWinMessage(message: GameSocketMessage) {
|
||||
const betWinData = extractBetWinData(message)
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
const period = extractPeriodEventData(message)
|
||||
const isJackpot = extractJackpotStatus(message)
|
||||
|
||||
if (!betWinData) {
|
||||
return
|
||||
}
|
||||
|
||||
useGameRoundStore.getState().setPendingBetWinReward({
|
||||
isJackpot: betWinData.is_jackpot,
|
||||
revealKey: `${betWinData.period_no}:${betWinData.result_number ?? 'pending'}:${betWinData.total_win}`,
|
||||
roundId: betWinData.period_no,
|
||||
totalWin: betWinData.total_win,
|
||||
winningCellId: betWinData.result_number,
|
||||
})
|
||||
|
||||
if (!currentUser) {
|
||||
return
|
||||
@@ -411,12 +613,12 @@ function applyJackpotHitMessage(message: GameSocketMessage) {
|
||||
|
||||
useAuthStore.getState().setCurrentUser({
|
||||
...currentUser,
|
||||
isJackpot,
|
||||
coin: betWinData.balance_after ?? currentUser.coin,
|
||||
currentStreak: betWinData.current_streak ?? currentUser.currentStreak,
|
||||
isJackpot: betWinData.is_jackpot,
|
||||
oddsFactor: betWinData.odds_factor ?? currentUser.oddsFactor,
|
||||
streakLevel: betWinData.streak_level ?? currentUser.streakLevel,
|
||||
})
|
||||
|
||||
if (isJackpot) {
|
||||
useGameRoundStore.getState().showJackpotReward(period?.periodNo ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
function applyRealtimeMessage(message: GameSocketMessage) {
|
||||
@@ -424,33 +626,9 @@ function applyRealtimeMessage(message: GameSocketMessage) {
|
||||
const topic = getMessageTopic(message)
|
||||
|
||||
switch (topic) {
|
||||
case GAME_SOCKET_TOPICS.periodTick: {
|
||||
const period = extractPeriodTick(message)
|
||||
const resultNumber =
|
||||
typeof period?.result_number === 'number' ? period.result_number : null
|
||||
const shouldStartSettledReveal =
|
||||
period?.status === 'settled' && resultNumber !== null
|
||||
const hasSmallReward = shouldStartSettledReveal
|
||||
? useGameRoundStore
|
||||
.getState()
|
||||
.selections.some((selection) => selection.cellId === resultNumber)
|
||||
: false
|
||||
|
||||
case GAME_SOCKET_TOPICS.periodTick:
|
||||
applyPeriodMessage(message, serverTime)
|
||||
|
||||
if (shouldStartSettledReveal) {
|
||||
useGameRoundStore.getState().prepareRevealAnimation({
|
||||
hasSmallReward,
|
||||
revealKey: `${period.period_no}:${resultNumber}`,
|
||||
roundId: period.period_no,
|
||||
winningCellId: resultNumber,
|
||||
})
|
||||
useGameRoundStore
|
||||
.getState()
|
||||
.playPreparedRevealAnimation(period.period_no)
|
||||
}
|
||||
break
|
||||
}
|
||||
case GAME_SOCKET_TOPICS.periodLocked:
|
||||
applyPeriodLockedMessage(message, serverTime)
|
||||
break
|
||||
@@ -469,6 +647,9 @@ function applyRealtimeMessage(message: GameSocketMessage) {
|
||||
case GAME_SOCKET_TOPICS.jackpotHit:
|
||||
applyJackpotHitMessage(message)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.betWin:
|
||||
applyBetWinMessage(message)
|
||||
break
|
||||
case GAME_SOCKET_TOPICS.betAccepted:
|
||||
case GAME_SOCKET_TOPICS.autoSpinProgress:
|
||||
break
|
||||
|
||||
@@ -1,42 +1,104 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import { Switch } from '@/components/ui/switch.tsx'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useModalStore } from '@/store'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import {
|
||||
type AutoHostingStopRules,
|
||||
selectSelectionTotal,
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
} from '@/store/game'
|
||||
|
||||
const AUTO_STOP_ROWS = [
|
||||
{
|
||||
labelKey: 'game.modals.autoSetting.rows.stopIfBalanceLowerThan',
|
||||
value: '0',
|
||||
checked: false,
|
||||
},
|
||||
{
|
||||
labelKey: 'game.modals.autoSetting.rows.stopIfSingleWinExceeds',
|
||||
value: '50000',
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
labelKey: 'game.modals.autoSetting.rows.stopOnAnyJackpot',
|
||||
// value: '50000',
|
||||
checked: false,
|
||||
},
|
||||
] as const
|
||||
function parseAmount(value: string) {
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
||||
}
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function DesktopAutoSettingModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopAutoSetting)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const selections = useGameRoundStore((state) => state.selections)
|
||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||
const startHosting = useGameAutoHostingStore((state) => state.startHosting)
|
||||
const [balanceLimitEnabled, setBalanceLimitEnabled] = useState(false)
|
||||
const [balanceLimitValue, setBalanceLimitValue] = useState('0')
|
||||
const [singleWinLimitEnabled, setSingleWinLimitEnabled] = useState(false)
|
||||
const [singleWinLimitValue, setSingleWinLimitValue] = useState('50000')
|
||||
const [jackpotStopEnabled, setJackpotStopEnabled] = useState(false)
|
||||
|
||||
function handleClose() {
|
||||
setModalOpen('desktopAutoSetting', false)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopAutoSetting', false)
|
||||
if (round.phase !== 'betting' || !round.id) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (selections.length === 0) {
|
||||
notify.warning(t('commonUi.toast.selectNumbersBeforeAutoHosting'))
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
const balance = parseBalance(currentUser?.coin)
|
||||
|
||||
if (totalBetAmount > balance) {
|
||||
notify.warning(t('commonUi.toast.insufficientBalance'))
|
||||
return
|
||||
}
|
||||
|
||||
const rules: AutoHostingStopRules = {
|
||||
stopIfBalanceBelow: {
|
||||
amount: parseAmount(balanceLimitValue),
|
||||
enabled: balanceLimitEnabled,
|
||||
},
|
||||
stopIfSingleWinAbove: {
|
||||
amount: parseAmount(singleWinLimitValue),
|
||||
enabled: singleWinLimitEnabled,
|
||||
},
|
||||
stopOnJackpot: jackpotStopEnabled,
|
||||
}
|
||||
|
||||
startHosting({
|
||||
balanceAfterBet: balance,
|
||||
rules,
|
||||
selections,
|
||||
})
|
||||
notify.success(t('commonUi.toast.autoHostingStarted'))
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||
{t('game.modals.autoSetting.title')}
|
||||
@@ -52,50 +114,96 @@ function DesktopAutoSettingModal() {
|
||||
}
|
||||
>
|
||||
<div className={'flex w-full flex-col gap-design-26'}>
|
||||
{AUTO_STOP_ROWS.map((row) => (
|
||||
<div className={'flex items-center justify-between gap-design-30'}>
|
||||
<div
|
||||
key={row.labelKey}
|
||||
className={'flex items-center justify-between gap-design-30'}
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{t(row.labelKey)}
|
||||
</div>
|
||||
|
||||
{'value' in row ? (
|
||||
<div
|
||||
className={
|
||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
defaultValue={row.value}
|
||||
className={
|
||||
'game-setting-input h-full w-design-280 text-design-18'
|
||||
}
|
||||
/>
|
||||
<Switch size={'sm'} defaultChecked={row.checked} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={'flex w-design-410 justify-end pr-design-2'}>
|
||||
<Switch size={'sm'} defaultChecked={row.checked} />
|
||||
</div>
|
||||
)}
|
||||
{t('game.modals.autoSetting.rows.stopIfBalanceLowerThan')}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={
|
||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={balanceLimitValue}
|
||||
inputMode="decimal"
|
||||
onChange={(event) => setBalanceLimitValue(event.target.value)}
|
||||
className={
|
||||
'game-setting-input h-full w-design-280 text-design-18'
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
size={'sm'}
|
||||
checked={balanceLimitEnabled}
|
||||
onCheckedChange={setBalanceLimitEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center justify-between gap-design-30'}>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.rows.stopIfSingleWinExceeds')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={singleWinLimitValue}
|
||||
inputMode="decimal"
|
||||
onChange={(event) => setSingleWinLimitValue(event.target.value)}
|
||||
className={
|
||||
'game-setting-input h-full w-design-280 text-design-18'
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
size={'sm'}
|
||||
checked={singleWinLimitEnabled}
|
||||
onCheckedChange={setSingleWinLimitEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center justify-between gap-design-30'}>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.rows.stopOnAnyJackpot')}
|
||||
</div>
|
||||
|
||||
<div className={'flex w-design-410 justify-end pr-design-2'}>
|
||||
<Switch
|
||||
size={'sm'}
|
||||
checked={jackpotStopEnabled}
|
||||
onCheckedChange={setJackpotStopEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex w-full justify-center'}>
|
||||
<SmartBackground
|
||||
as="button"
|
||||
src={lengthBlueBtn}
|
||||
size="100% 100%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className={
|
||||
'w-design-300 h-design-72 pb-design-4 flex items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF]'
|
||||
'w-design-300 h-design-72 pb-design-4 flex cursor-pointer items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF] transition-transform hover:-translate-y-[1px] active:translate-y-0'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.startAutoSpin')}
|
||||
|
||||
@@ -22,7 +22,7 @@ function DesktopRegisterModal() {
|
||||
</div>
|
||||
}
|
||||
titleAlign="center"
|
||||
className={'w-design-980 h-design-740'}
|
||||
className={'w-design-980 h-design-840'}
|
||||
>
|
||||
<DesktopRegisterForm onSuccess={handleSubmit} />
|
||||
</CenterModal>
|
||||
|
||||
@@ -55,8 +55,8 @@ type GameSocketClientOptions = {
|
||||
|
||||
function toQueryString(context: GameSocketContext) {
|
||||
const params = new URLSearchParams({
|
||||
token: context.token,
|
||||
auth_token: context.authToken,
|
||||
'user-token': context.token,
|
||||
'auth-token': context.authToken,
|
||||
device_id: context.deviceId,
|
||||
lang: context.lang,
|
||||
})
|
||||
|
||||
@@ -198,6 +198,7 @@ export default {
|
||||
title: 'Auto spin running',
|
||||
description:
|
||||
'Auto mode will cover the board while preserving target cell focus and progress.',
|
||||
runningRounds: 'Auto spin running, {{count}} rounds completed',
|
||||
trailingLabel: 'Manual input locked',
|
||||
},
|
||||
footer: {
|
||||
@@ -237,6 +238,15 @@ export default {
|
||||
'Selections from the last successful round have been restored',
|
||||
betRejected: 'Bet was not accepted',
|
||||
betPlaceFailed: 'Failed to place the bet. Please try again.',
|
||||
selectNumbersBeforeAutoHosting: 'Please select numbers first',
|
||||
autoHostingStarted: 'Auto spin started',
|
||||
autoHostingStopped: 'Auto spin stopped',
|
||||
autoHostingStoppedBalance:
|
||||
'Balance condition reached. Auto spin has stopped.',
|
||||
autoHostingStoppedWin:
|
||||
'Single-win condition reached. Auto spin has stopped.',
|
||||
autoHostingStoppedJackpot: 'Jackpot reached. Auto spin has stopped.',
|
||||
autoHostingSubmitFailed: 'Auto spin bet failed. Auto spin has stopped.',
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
|
||||
@@ -197,6 +197,7 @@ export default {
|
||||
title: 'Auto spin berjalan',
|
||||
description:
|
||||
'Mode auto akan menutupi board sambil mempertahankan fokus sel target dan progres.',
|
||||
runningRounds: 'Auto spin berjalan, {{count}} ronde selesai',
|
||||
trailingLabel: 'Input manual terkunci',
|
||||
},
|
||||
footer: {
|
||||
@@ -236,6 +237,16 @@ export default {
|
||||
'Pilihan dari ronde berhasil terakhir telah dipulihkan',
|
||||
betRejected: 'Taruhan tidak diterima',
|
||||
betPlaceFailed: 'Gagal mengirim taruhan. Silakan coba lagi.',
|
||||
selectNumbersBeforeAutoHosting: 'Pilih angka terlebih dahulu',
|
||||
autoHostingStarted: 'Auto spin dimulai',
|
||||
autoHostingStopped: 'Auto spin berhenti',
|
||||
autoHostingStoppedBalance:
|
||||
'Kondisi saldo tercapai. Auto spin telah berhenti.',
|
||||
autoHostingStoppedWin:
|
||||
'Kondisi kemenangan tunggal tercapai. Auto spin telah berhenti.',
|
||||
autoHostingStoppedJackpot: 'Jackpot tercapai. Auto spin telah berhenti.',
|
||||
autoHostingSubmitFailed:
|
||||
'Taruhan auto spin gagal. Auto spin telah berhenti.',
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
|
||||
@@ -200,6 +200,7 @@ export default {
|
||||
title: 'Putaran auto sedang berjalan',
|
||||
description:
|
||||
'Mod auto akan menutup papan sambil mengekalkan fokus sel sasaran dan kemajuan.',
|
||||
runningRounds: 'Putaran auto berjalan, {{count}} pusingan selesai',
|
||||
trailingLabel: 'Input manual dikunci',
|
||||
},
|
||||
footer: {
|
||||
@@ -239,6 +240,17 @@ export default {
|
||||
'Pilihan dari pusingan berjaya terakhir telah dipulihkan',
|
||||
betRejected: 'Taruhan tidak diterima',
|
||||
betPlaceFailed: 'Gagal menghantar taruhan. Sila cuba lagi.',
|
||||
selectNumbersBeforeAutoHosting: 'Sila pilih nombor terlebih dahulu',
|
||||
autoHostingStarted: 'Putaran auto bermula',
|
||||
autoHostingStopped: 'Putaran auto dihentikan',
|
||||
autoHostingStoppedBalance:
|
||||
'Syarat baki dicapai. Putaran auto telah dihentikan.',
|
||||
autoHostingStoppedWin:
|
||||
'Syarat kemenangan tunggal dicapai. Putaran auto telah dihentikan.',
|
||||
autoHostingStoppedJackpot:
|
||||
'Jackpot dicapai. Putaran auto telah dihentikan.',
|
||||
autoHostingSubmitFailed:
|
||||
'Taruhan putaran auto gagal. Putaran auto telah dihentikan.',
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
|
||||
@@ -152,7 +152,7 @@ export default {
|
||||
message: '站内消息',
|
||||
},
|
||||
profile: {
|
||||
name: '姓名',
|
||||
name: '用户名',
|
||||
tel: '电话',
|
||||
registeredAt: '注册时间',
|
||||
signature: '我的个性签名和灵魂一样独特,后续这里会接真实资料字段。',
|
||||
@@ -192,6 +192,7 @@ export default {
|
||||
eyebrow: '自动托管',
|
||||
title: '自动托管运行中',
|
||||
description: '托管态会覆盖主盘面,但目标格子和进度信息仍然保留可见。',
|
||||
runningRounds: '游戏托管中,已经进行 {{count}} 局',
|
||||
trailingLabel: '手动输入已锁定',
|
||||
},
|
||||
footer: {
|
||||
@@ -229,6 +230,13 @@ export default {
|
||||
repeatSelectionsRestored: '已恢复上一局成功下注的花字',
|
||||
betRejected: '下注未受理',
|
||||
betPlaceFailed: '下注失败,请稍后重试',
|
||||
selectNumbersBeforeAutoHosting: '请先选择花字',
|
||||
autoHostingStarted: '自动托管已开始',
|
||||
autoHostingStopped: '自动托管已停止',
|
||||
autoHostingStoppedBalance: '余额低于条件,自动托管已停止',
|
||||
autoHostingStoppedWin: '单次盈利达到条件,自动托管已停止',
|
||||
autoHostingStoppedJackpot: '出现 Jackpot 大奖,自动托管已停止',
|
||||
autoHostingSubmitFailed: '自动托管下注失败,已停止托管',
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
|
||||
@@ -16,6 +16,7 @@ const TEST_TOPICS = [
|
||||
'admin.live.snapshot',
|
||||
'admin.live.opened',
|
||||
'jackpot.hit',
|
||||
'bet.win',
|
||||
] as const
|
||||
|
||||
type WsTestLog = {
|
||||
|
||||
90
src/store/game/game-auto-hosting-store.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
import type { BetSelection } from '@/features/game/shared'
|
||||
|
||||
export interface AutoHostingStopRules {
|
||||
stopIfBalanceBelow: {
|
||||
amount: number
|
||||
enabled: boolean
|
||||
}
|
||||
stopIfSingleWinAbove: {
|
||||
amount: number
|
||||
enabled: boolean
|
||||
}
|
||||
stopOnJackpot: boolean
|
||||
}
|
||||
|
||||
interface StartAutoHostingInput {
|
||||
balanceAfterBet: number | null
|
||||
rules: AutoHostingStopRules
|
||||
selections: BetSelection[]
|
||||
}
|
||||
|
||||
export interface GameAutoHostingStoreState {
|
||||
balanceAfterBet: number | null
|
||||
completedRounds: number
|
||||
isHosting: boolean
|
||||
lastSubmittedRoundId: string | null
|
||||
rules: AutoHostingStopRules
|
||||
selections: BetSelection[]
|
||||
markRoundSubmitted: (roundId: string, balanceAfterBet: number | null) => void
|
||||
startHosting: (input: StartAutoHostingInput) => void
|
||||
stopHosting: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_AUTO_HOSTING_RULES: AutoHostingStopRules = {
|
||||
stopIfBalanceBelow: {
|
||||
amount: 0,
|
||||
enabled: false,
|
||||
},
|
||||
stopIfSingleWinAbove: {
|
||||
amount: 50_000,
|
||||
enabled: false,
|
||||
},
|
||||
stopOnJackpot: false,
|
||||
}
|
||||
|
||||
export const useGameAutoHostingStore = create<GameAutoHostingStoreState>()(
|
||||
(set) => ({
|
||||
balanceAfterBet: null,
|
||||
completedRounds: 0,
|
||||
isHosting: false,
|
||||
lastSubmittedRoundId: null,
|
||||
rules: DEFAULT_AUTO_HOSTING_RULES,
|
||||
selections: [],
|
||||
markRoundSubmitted: (roundId, balanceAfterBet) => {
|
||||
set((state) => {
|
||||
if (!state.isHosting || state.lastSubmittedRoundId === roundId) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
balanceAfterBet,
|
||||
completedRounds: state.completedRounds + 1,
|
||||
lastSubmittedRoundId: roundId,
|
||||
}
|
||||
})
|
||||
},
|
||||
startHosting: ({ balanceAfterBet, rules, selections }) => {
|
||||
set({
|
||||
balanceAfterBet,
|
||||
completedRounds: 0,
|
||||
isHosting: true,
|
||||
lastSubmittedRoundId: null,
|
||||
rules,
|
||||
selections: selections.map((selection) => ({
|
||||
...selection,
|
||||
})),
|
||||
})
|
||||
},
|
||||
stopHosting: () => {
|
||||
set({
|
||||
balanceAfterBet: null,
|
||||
completedRounds: 0,
|
||||
isHosting: false,
|
||||
lastSubmittedRoundId: null,
|
||||
selections: [],
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -35,9 +35,13 @@ export type RevealAnimationPhase = 'idle' | 'spinning' | 'stopping' | 'result'
|
||||
export type RewardAnimationType = 'none' | 'small' | 'big'
|
||||
|
||||
export interface RevealAnimationState {
|
||||
pendingRewardAmount: string | null
|
||||
pendingRewardKey: string | null
|
||||
pendingRewardRoundId: string | null
|
||||
pendingRewardType: RewardAnimationType
|
||||
phase: RevealAnimationPhase
|
||||
revealKey: string | null
|
||||
rewardAmount: string | null
|
||||
rewardType: RewardAnimationType
|
||||
roundId: string | null
|
||||
winningCellId: number | null
|
||||
@@ -45,15 +49,39 @@ export interface RevealAnimationState {
|
||||
|
||||
function createIdleRevealAnimation(): RevealAnimationState {
|
||||
return {
|
||||
pendingRewardAmount: null,
|
||||
pendingRewardKey: null,
|
||||
pendingRewardRoundId: null,
|
||||
pendingRewardType: 'none',
|
||||
phase: 'idle',
|
||||
revealKey: null,
|
||||
rewardAmount: null,
|
||||
rewardType: 'none',
|
||||
roundId: null,
|
||||
winningCellId: null,
|
||||
}
|
||||
}
|
||||
|
||||
function createIdleRevealAnimationPreservingReward(
|
||||
current: RevealAnimationState,
|
||||
): RevealAnimationState {
|
||||
if (current.rewardType === 'none' && current.pendingRewardType === 'none') {
|
||||
return createIdleRevealAnimation()
|
||||
}
|
||||
|
||||
return {
|
||||
...createIdleRevealAnimation(),
|
||||
pendingRewardAmount: current.pendingRewardAmount,
|
||||
pendingRewardKey: current.pendingRewardKey,
|
||||
pendingRewardRoundId: current.pendingRewardRoundId,
|
||||
pendingRewardType: current.pendingRewardType,
|
||||
revealKey: current.revealKey,
|
||||
rewardAmount: current.rewardAmount,
|
||||
rewardType: current.rewardType,
|
||||
roundId: current.roundId,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRecentActiveChipId(
|
||||
chips: Chip[],
|
||||
selections: BetSelection[],
|
||||
@@ -83,7 +111,6 @@ export interface GameRoundStoreState extends GameRoundSlice {
|
||||
placeBet: (cellId: number) => void
|
||||
playPreparedRevealAnimation: (roundId?: string | null) => void
|
||||
prepareRevealAnimation: (input: {
|
||||
hasSmallReward: boolean
|
||||
revealKey: string
|
||||
roundId: string
|
||||
winningCellId: number
|
||||
@@ -95,7 +122,13 @@ export interface GameRoundStoreState extends GameRoundSlice {
|
||||
setRecentSuccessfulSelections: (selections: BetSelection[]) => void
|
||||
selectChip: (chipId: string) => void
|
||||
setPhase: (phase: RoundPhase) => void
|
||||
showJackpotReward: (roundId?: string | null) => void
|
||||
setPendingBetWinReward: (input: {
|
||||
isJackpot: boolean
|
||||
revealKey: string
|
||||
roundId?: string | null
|
||||
totalWin: string
|
||||
winningCellId?: number | null
|
||||
}) => void
|
||||
syncRound: (round: Partial<RoundSnapshot>) => void
|
||||
upsertSelections: (selections: BetSelection[]) => void
|
||||
}
|
||||
@@ -130,6 +163,11 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
set((state) => ({
|
||||
revealAnimation: {
|
||||
...state.revealAnimation,
|
||||
pendingRewardAmount: null,
|
||||
pendingRewardKey: null,
|
||||
pendingRewardRoundId: null,
|
||||
pendingRewardType: 'none',
|
||||
rewardAmount: null,
|
||||
rewardType: 'none',
|
||||
},
|
||||
}))
|
||||
@@ -143,16 +181,31 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
return state
|
||||
}
|
||||
|
||||
const rewardType =
|
||||
state.revealAnimation.rewardType === 'big'
|
||||
? 'big'
|
||||
: state.revealAnimation.pendingRewardType
|
||||
const hasPendingReward =
|
||||
state.revealAnimation.pendingRewardType !== 'none' &&
|
||||
state.revealAnimation.pendingRewardRoundId ===
|
||||
state.revealAnimation.roundId
|
||||
|
||||
return {
|
||||
revealAnimation: {
|
||||
...state.revealAnimation,
|
||||
pendingRewardAmount: null,
|
||||
pendingRewardKey: null,
|
||||
pendingRewardRoundId: null,
|
||||
pendingRewardType: 'none',
|
||||
phase: 'result',
|
||||
rewardType,
|
||||
revealKey: hasPendingReward
|
||||
? state.revealAnimation.pendingRewardKey
|
||||
: state.revealAnimation.revealKey,
|
||||
rewardAmount: hasPendingReward
|
||||
? state.revealAnimation.pendingRewardAmount
|
||||
: state.revealAnimation.rewardAmount,
|
||||
rewardType: hasPendingReward
|
||||
? state.revealAnimation.pendingRewardType
|
||||
: state.revealAnimation.rewardType,
|
||||
roundId: hasPendingReward
|
||||
? state.revealAnimation.pendingRewardRoundId
|
||||
: state.revealAnimation.roundId,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -170,7 +223,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
maxSelectionCount: snapshot.maxSelectionCount,
|
||||
revealAnimation:
|
||||
snapshot.round.phase === 'betting' || snapshot.round.phase === 'waiting'
|
||||
? createIdleRevealAnimation()
|
||||
? createIdleRevealAnimationPreservingReward(state.revealAnimation)
|
||||
: state.revealAnimation,
|
||||
round: snapshot.round,
|
||||
selections: snapshot.selections,
|
||||
@@ -235,34 +288,17 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
}
|
||||
})
|
||||
},
|
||||
prepareRevealAnimation: ({
|
||||
hasSmallReward,
|
||||
revealKey,
|
||||
roundId,
|
||||
winningCellId,
|
||||
}) => {
|
||||
prepareRevealAnimation: ({ revealKey, roundId, winningCellId }) => {
|
||||
set((state) => {
|
||||
if (state.revealAnimation.revealKey === revealKey) {
|
||||
return state
|
||||
}
|
||||
|
||||
const pendingRewardType =
|
||||
state.revealAnimation.pendingRewardType === 'big'
|
||||
? 'big'
|
||||
: hasSmallReward
|
||||
? 'small'
|
||||
: 'none'
|
||||
|
||||
return {
|
||||
revealAnimation: {
|
||||
...state.revealAnimation,
|
||||
pendingRewardType,
|
||||
phase: 'idle',
|
||||
revealKey,
|
||||
rewardType:
|
||||
state.revealAnimation.rewardType === 'big'
|
||||
? 'big'
|
||||
: state.revealAnimation.rewardType,
|
||||
roundId,
|
||||
winningCellId,
|
||||
},
|
||||
@@ -346,24 +382,53 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
}
|
||||
|
||||
if (phase === 'betting' || phase === 'waiting') {
|
||||
nextState.revealAnimation = createIdleRevealAnimation()
|
||||
nextState.revealAnimation = createIdleRevealAnimationPreservingReward(
|
||||
state.revealAnimation,
|
||||
)
|
||||
}
|
||||
|
||||
return nextState
|
||||
})
|
||||
},
|
||||
showJackpotReward: (roundId) => {
|
||||
setPendingBetWinReward: ({
|
||||
isJackpot,
|
||||
revealKey,
|
||||
roundId,
|
||||
totalWin,
|
||||
winningCellId,
|
||||
}) => {
|
||||
set((state) => {
|
||||
const nextRoundId =
|
||||
roundId ?? state.round.id ?? state.revealAnimation.roundId
|
||||
const isResultReady = state.revealAnimation.phase === 'result'
|
||||
const nextWinningCellId =
|
||||
winningCellId ?? state.revealAnimation.winningCellId
|
||||
const rewardType = isJackpot ? 'big' : 'small'
|
||||
const shouldQueueReward =
|
||||
state.revealAnimation.phase !== 'result' ||
|
||||
state.revealAnimation.roundId !== nextRoundId
|
||||
|
||||
if (shouldQueueReward) {
|
||||
return {
|
||||
revealAnimation: {
|
||||
...state.revealAnimation,
|
||||
pendingRewardAmount: totalWin,
|
||||
pendingRewardKey: revealKey,
|
||||
pendingRewardRoundId: nextRoundId,
|
||||
pendingRewardType: rewardType,
|
||||
roundId: nextRoundId,
|
||||
winningCellId: nextWinningCellId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
revealAnimation: {
|
||||
...state.revealAnimation,
|
||||
pendingRewardType: 'big',
|
||||
rewardType: isResultReady ? 'big' : state.revealAnimation.rewardType,
|
||||
revealKey,
|
||||
rewardAmount: totalWin,
|
||||
rewardType,
|
||||
roundId: nextRoundId,
|
||||
winningCellId: nextWinningCellId,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -392,7 +457,9 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
||||
nextRound.id !== previousRound.id &&
|
||||
(nextRound.phase === 'betting' || nextRound.phase === 'waiting')
|
||||
) {
|
||||
nextState.revealAnimation = createIdleRevealAnimation()
|
||||
nextState.revealAnimation = createIdleRevealAnimationPreservingReward(
|
||||
state.revealAnimation,
|
||||
)
|
||||
}
|
||||
|
||||
return nextState
|
||||
|
||||
@@ -18,10 +18,25 @@ type GameSessionSlice = Pick<
|
||||
'announcements' | 'connection' | 'dashboard'
|
||||
>
|
||||
|
||||
const MAX_JACKPOT_BROADCAST_COUNT = 20
|
||||
|
||||
export interface JackpotBroadcastItem {
|
||||
id: string
|
||||
message: string
|
||||
periodNo: string
|
||||
receivedAt: string
|
||||
totalWin: string
|
||||
userId: number
|
||||
}
|
||||
|
||||
type JackpotBroadcastInput = Omit<JackpotBroadcastItem, 'receivedAt'>
|
||||
|
||||
export interface GameSessionStoreState extends GameSessionSlice {
|
||||
dismissAnnouncement: (announcementId: string) => void
|
||||
hydrateSession: (snapshot: GameSessionSlice) => void
|
||||
jackpotBroadcasts: JackpotBroadcastItem[]
|
||||
markAnnouncementRead: (announcementId: string) => void
|
||||
pushJackpotBroadcasts: (broadcasts: JackpotBroadcastInput[]) => void
|
||||
requestRealtimeConnection: () => void
|
||||
resetRealtimeConnectionRequest: () => void
|
||||
shouldConnectRealtime: boolean
|
||||
@@ -43,6 +58,7 @@ function createInitialSessionState(): GameSessionSlice {
|
||||
|
||||
export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
|
||||
...createInitialSessionState(),
|
||||
jackpotBroadcasts: [],
|
||||
shouldConnectRealtime: false,
|
||||
dismissAnnouncement: (announcementId) => {
|
||||
set((state) => ({
|
||||
@@ -63,6 +79,7 @@ export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
|
||||
hydrateSession: (snapshot) => {
|
||||
set((state) => ({
|
||||
...snapshot,
|
||||
jackpotBroadcasts: state.jackpotBroadcasts,
|
||||
shouldConnectRealtime: state.shouldConnectRealtime,
|
||||
}))
|
||||
},
|
||||
@@ -76,6 +93,35 @@ export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
|
||||
},
|
||||
}))
|
||||
},
|
||||
pushJackpotBroadcasts: (broadcasts) => {
|
||||
if (broadcasts.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const existingIds = new Set(
|
||||
state.jackpotBroadcasts.map((item) => item.id),
|
||||
)
|
||||
const receivedAt = new Date().toISOString()
|
||||
const nextBroadcasts = broadcasts
|
||||
.filter((broadcast) => !existingIds.has(broadcast.id))
|
||||
.map((broadcast) => ({
|
||||
...broadcast,
|
||||
receivedAt,
|
||||
}))
|
||||
|
||||
if (nextBroadcasts.length === 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
jackpotBroadcasts: [
|
||||
...nextBroadcasts,
|
||||
...state.jackpotBroadcasts,
|
||||
].slice(0, MAX_JACKPOT_BROADCAST_COUNT),
|
||||
}
|
||||
})
|
||||
},
|
||||
requestRealtimeConnection: () => {
|
||||
set({ shouldConnectRealtime: true })
|
||||
},
|
||||
@@ -138,7 +184,7 @@ export const selectVisibleAnnouncements = (state: GameSessionStoreState) =>
|
||||
export type GameSessionStore = typeof useGameSessionStore
|
||||
export type GameSessionStoreData = Pick<
|
||||
GameSessionStoreState,
|
||||
'announcements' | 'connection' | 'dashboard'
|
||||
'announcements' | 'connection' | 'dashboard' | 'jackpotBroadcasts'
|
||||
>
|
||||
|
||||
export type { AnnouncementState, ConnectionState, DashboardState }
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './game-auto-hosting-store'
|
||||
export * from './game-round-store'
|
||||
export * from './game-session-store'
|
||||
|
||||
@@ -348,6 +348,135 @@
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktop-title-vertical-marquee {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: desktop-title-marquee-y 7s linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes desktop-title-marquee-y {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@property --gold-angle {
|
||||
syntax: "<angle>";
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
@keyframes rotating-gold-border {
|
||||
to {
|
||||
--gold-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gold-border-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.34;
|
||||
transform: scale(0.992);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.54;
|
||||
transform: scale(1.008);
|
||||
}
|
||||
}
|
||||
|
||||
.gold-reveal-shell {
|
||||
--gold-angle: 0deg;
|
||||
position: absolute;
|
||||
inset: calc(var(--design-unit) * 3.2);
|
||||
border-radius: calc(var(--design-unit) * 16);
|
||||
overflow: hidden;
|
||||
clip-path: inset(0 round calc(var(--design-unit) * 16));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gold-reveal-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: calc(var(--design-unit) * 16);
|
||||
padding: calc(var(--design-unit) * 2.6);
|
||||
background: conic-gradient(
|
||||
from var(--gold-angle),
|
||||
#534217 10%,
|
||||
#534217 20%,
|
||||
#ffe226 45%,
|
||||
#534217 60%,
|
||||
#534217 85%,
|
||||
#ffe226 95%,
|
||||
#534217 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
animation: rotating-gold-border 8s linear infinite;
|
||||
}
|
||||
|
||||
.gold-reveal-shell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: calc(var(--design-unit) * 0.8);
|
||||
border-radius: calc(var(--design-unit) * 15);
|
||||
padding: calc(var(--design-unit) * 1.8);
|
||||
background: conic-gradient(
|
||||
from calc(var(--gold-angle) + 180deg),
|
||||
rgba(255, 247, 210, 0) 0%,
|
||||
rgba(255, 247, 210, 0) 66%,
|
||||
rgba(255, 252, 232, 0.14) 71%,
|
||||
rgba(255, 254, 244, 0.98) 75%,
|
||||
rgba(255, 230, 130, 0.96) 79%,
|
||||
rgba(255, 247, 210, 0.18) 84%,
|
||||
rgba(255, 247, 210, 0) 90%,
|
||||
rgba(255, 247, 210, 0) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
animation: rotating-gold-border 2.1s linear infinite;
|
||||
}
|
||||
|
||||
.gold-reveal-static-border {
|
||||
position: absolute;
|
||||
inset: calc(var(--design-unit) * 3.2);
|
||||
border-radius: calc(var(--design-unit) * 16);
|
||||
border: calc(var(--design-unit) * 3.8) solid rgba(181, 138, 40, 0.98);
|
||||
box-shadow:
|
||||
inset 0 0 calc(var(--design-unit) * 10) rgba(255, 241, 181, 0.28),
|
||||
0 0 calc(var(--design-unit) * 12) rgba(255, 210, 102, 0.2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gold-reveal-glow {
|
||||
position: absolute;
|
||||
inset: calc(var(--design-unit) * 1.4);
|
||||
border-radius: calc(var(--design-unit) * 18);
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
rgba(255, 226, 92, 0.18) 0%,
|
||||
rgba(219, 161, 42, 0.14) 42%,
|
||||
rgba(127, 86, 13, 0.08) 62%,
|
||||
transparent 82%
|
||||
);
|
||||
filter: blur(calc(var(--design-unit) * 2.8));
|
||||
opacity: 0.36;
|
||||
pointer-events: none;
|
||||
animation: gold-border-pulse 1.9s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||