Compare commits
5 Commits
0d923491b5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a620a84c5 | ||
|
|
7d68652ea5 | ||
|
|
35bd1f58dd | ||
|
|
1cd3dbcd72 | ||
|
|
c97249f2ca |
@@ -1,57 +1,63 @@
|
|||||||
---
|
---
|
||||||
description: Create a new Ticket
|
description: Converts conversational brain dumps into structured, metric-driven Markdown tickets in the ./tickets directory.
|
||||||
---
|
---
|
||||||
|
|
||||||
### Role
|
# WORKFLOW: PRAGMATIC ARCHITECT TICKET GENERATOR
|
||||||
You are a Senior Technical Product Manager and Lead Engineer. Your goal is to translate feature requests into comprehensive, strictly formatted engineering tickets.
|
|
||||||
|
|
||||||
### Task
|
## 1. High-Level Goal
|
||||||
When I ask you to "scope a feature" or "create a ticket" for a specific functionality:
|
Transform informal user "brain dumps" into high-precision, metric-driven engineering tickets stored as Markdown files in the `./tickets/` directory. The workflow enforces a quality gate via targeted inquiry before any file persistence occurs, ensuring all tasks are observable, measurable, and actionable.
|
||||||
1. Analyze the request for technical implications, edge cases, and architectural fit.
|
|
||||||
2. Generate a new Markdown file.
|
|
||||||
3. Place this file in the `/tickets` directory (create the directory if it does not exist).
|
|
||||||
|
|
||||||
### File Naming Convention
|
## 2. Assumptions & Clarifications
|
||||||
You must use the following naming convention strictly:
|
- **Assumptions:** The agent has write access to the `./tickets/` and `/temp/` directories. The current date is accessible for naming conventions. "Metrics" refer to quantifiable constraints (latency, line counts, status codes).
|
||||||
`/tickets/YYYY-MM-DD-{kebab-case-feature-name}.md`
|
- **Ambiguities:** If the user provides a second brain dump while a ticket is in progress, the agent will prioritize the current workflow until completion or explicit cancellation.
|
||||||
|
|
||||||
*Example:* `/tickets/2024-10-12-user-authentication-flow.md`
|
## 3. Stage Breakdown
|
||||||
|
|
||||||
### File Content Structure
|
### Stage 1: Discovery & Quality Gate
|
||||||
The markdown file must adhere to the following template exactly. Do not skip sections. If a section is not applicable, write "N/A" but explain why.
|
- **Stage Name:** Requirement Analysis
|
||||||
|
- **Purpose:** Analyze input for vagueness and enforce the "Quality Gate" by extracting metrics.
|
||||||
|
- **Inputs:** Raw user brain dump (text).
|
||||||
|
- **Actions:** 1. Identify "Known Unknowns" (vague terms like "fast," "better," "clean").
|
||||||
|
2. Formulate exactly three (3) targeted questions to convert vague goals into comparable metrics.
|
||||||
|
3. Check for logical inconsistencies in the request.
|
||||||
|
- **Outputs:** Three questions presented to the user.
|
||||||
|
- **Persistence Strategy:** Save the original brain dump and the three questions to `/temp/pending_ticket_state.json`.
|
||||||
|
|
||||||
```markdown
|
### Stage 2: Drafting & Refinement
|
||||||
# [Ticket ID]: [Feature Title]
|
- **Stage Name:** Ticket Drafting
|
||||||
|
- **Purpose:** Synthesize the original dump and user answers into a structured Markdown draft.
|
||||||
|
- **Inputs:** User responses to the three questions; `/temp/pending_ticket_state.json`.
|
||||||
|
- **Actions:** 1. Construct a Markdown draft using the provided template.
|
||||||
|
2. Generate a slug-based filename: `YYYYMMDD-slug.md`.
|
||||||
|
3. Present the draft and filename to the user for review.
|
||||||
|
- **Outputs:** Formatted Markdown text and suggested filename displayed in the chat.
|
||||||
|
- **Persistence Strategy:** Update `/temp/pending_ticket_state.json` with the full Markdown content and the proposed filename.
|
||||||
|
|
||||||
**Status:** Draft
|
### Stage 3: Execution & Persistence
|
||||||
**Created:** [YYYY-MM-DD]
|
- **Stage Name:** Finalization
|
||||||
**Tags:** [comma, separated, tags]
|
- **Purpose:** Commit the approved ticket to the permanent `./tickets/` directory.
|
||||||
|
- **Inputs:** User confirmation (e.g., "Go," "Approved"); `/temp/pending_ticket_state.json`.
|
||||||
|
- **Actions:** 1. Write the finalized Markdown content to `./tickets/[filename]`.
|
||||||
|
2. Delete the temporary state file in `/temp/`.
|
||||||
|
- **Outputs:** Confirmation message containing the relative path to the new file.
|
||||||
|
- **Persistence Strategy:** Permanent write to `./tickets/`.
|
||||||
|
|
||||||
## 1. Context & User Story
|
## 4. Data & File Contracts
|
||||||
* **As a:** [Role]
|
- **State File:** `/temp/pending_ticket_state.json`
|
||||||
* **I want to:** [Action]
|
- Schema: `{ "original_input": string, "questions": string[], "answers": string[], "draft_content": string, "filename": string, "step": integer }`
|
||||||
* **So that:** [Benefit/Value]
|
- **Output File:** `./tickets/YYYYMMDD-[slug].md`
|
||||||
|
- Format: Markdown
|
||||||
|
- Sections: `# Title`, `## Context`, `## Acceptance Criteria`, `## Suggested Affected Files`, `## Technical Constraints`.
|
||||||
|
|
||||||
## 2. Technical Requirements
|
## 5. Failure & Recovery Handling
|
||||||
### Data Model Changes
|
- **Incomplete Inputs:** If the user fails to answer the 3 questions, the agent must politely restate that metrics are required for high-precision engineering and repeat the questions.
|
||||||
- [ ] Describe any new tables, columns, or relationship changes.
|
- **Inconsistencies:** If the user’s answers contradict the original dump, the agent must flag the contradiction and ask for a tie-break before drafting.
|
||||||
- [ ] SQL migration required? (Yes/No)
|
- **Missing Directory:** If `./tickets/` does not exist during Stage 3, the agent must attempt to create it before writing the file.
|
||||||
|
|
||||||
### API / Interface
|
## 6. Final Deliverable Specification
|
||||||
- [ ] Define endpoints (method, path) or function signatures.
|
- **Format:** A valid Markdown file in the `./tickets/` folder.
|
||||||
- [ ] Payload definition (JSON structure or Types).
|
- **Quality Bar:**
|
||||||
|
- Zero fluff in the Context section.
|
||||||
## 3. Constraints & Validations (CRITICAL)
|
- All Acceptance Criteria must be binary (pass/fail) or metric-based.
|
||||||
*This section must be exhaustive. Do not be vague.*
|
- Filename must strictly follow `YYYYMMDD-slug.md` (e.g., `20240520-auth-refactor.md`).
|
||||||
- **Input Validation:** (e.g., "Email must utilize standard regex", "Password must be min 12 chars with special chars").
|
- No "Status" or "Priority" fields.
|
||||||
- **System Constraints:** (e.g., "Image upload max size 5MB", "Request timeout 30s").
|
|
||||||
- **Business Logic Guardrails:** (e.g., "User cannot upgrade if balance < $0").
|
|
||||||
|
|
||||||
## 4. Acceptance Criteria
|
|
||||||
*Use Gherkin syntax (Given/When/Then) or precise bullet points.*
|
|
||||||
1. [ ] Criteria 1
|
|
||||||
2. [ ] Criteria 2
|
|
||||||
|
|
||||||
## 5. Implementation Plan
|
|
||||||
- [ ] Step 1: ...
|
|
||||||
- [ ] Step 2: ...
|
|
||||||
89
.agent/workflows/map-impact.md
Normal file
89
.agent/workflows/map-impact.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
description: Analyzes the codebase to find dependencies and side effects related to a specific ticket.
|
||||||
|
---
|
||||||
|
|
||||||
|
# WORKFLOW: Dependency Architect & Blast Radius Analysis
|
||||||
|
|
||||||
|
## 1. High-Level Goal
|
||||||
|
Perform a deterministic "Blast Radius" analysis for a code change defined in a Jira/Linear-style ticket. The agent will identify direct consumers, side effects, and relevant test suites, then append a structured "Impact Analysis" section to the original ticket file to guide developers and ensure high-velocity execution without regressions.
|
||||||
|
|
||||||
|
## 2. Assumptions & Clarifications
|
||||||
|
- **Location:** Tickets are stored in the `./tickets/` directory as Markdown files.
|
||||||
|
- **Code Access:** The agent has full read access to the project root and subdirectories.
|
||||||
|
- **Scope:** Dependency tracing is limited to "one level deep" (direct imports/references) unless a global configuration or core database schema change is detected.
|
||||||
|
- **Ambiguity Handling:** If "Suggested Affected Files" are missing from the ticket, the agent will attempt to infer them from the "Acceptance Criteria" logic; if inference is impossible, the agent will halt and request the file list.
|
||||||
|
|
||||||
|
## 3. Stage Breakdown
|
||||||
|
|
||||||
|
### Stage 1: Ticket Parsing & Context Extraction
|
||||||
|
- **Purpose:** Extract the specific files and logic constraints requiring analysis.
|
||||||
|
- **Inputs:** A specific ticket filename (e.g., `./tickets/TASK-123.md`).
|
||||||
|
- **Actions:**
|
||||||
|
1. Read the ticket file.
|
||||||
|
2. Extract the list of "Suggested Affected Files".
|
||||||
|
3. Extract keywords and logic from the "Acceptance Criteria".
|
||||||
|
4. Validate that all "Suggested Affected Files" exist in the current codebase.
|
||||||
|
- **Outputs:** A JSON object containing the target file list and key logic requirements.
|
||||||
|
- **Persistence Strategy:** Save extracted data to `/temp/context.json`.
|
||||||
|
|
||||||
|
### Stage 2: Recursive Dependency Mapping
|
||||||
|
- **Purpose:** Identify which external modules rely on the target files.
|
||||||
|
- **Inputs:** `/temp/context.json`.
|
||||||
|
- **Actions:**
|
||||||
|
1. For each file in the target list, perform a search (e.g., `grep` or AST walk) for import statements or references in the rest of the codebase.
|
||||||
|
2. Filter out internal references within the same module (focus on external consumers).
|
||||||
|
3. Detect if the change involves shared utilities (e.g., `utils/`, `common/`) or database schemas (e.g., `prisma/schema.prisma`).
|
||||||
|
- **Outputs:** A list of unique consumer file paths and their specific usage context.
|
||||||
|
- **Persistence Strategy:** Save findings to `/temp/dependencies.json`.
|
||||||
|
|
||||||
|
### Stage 3: Test Suite Identification
|
||||||
|
- **Purpose:** Locate the specific test files required to validate the change.
|
||||||
|
- **Inputs:** `/temp/context.json` and `/temp/dependencies.json`.
|
||||||
|
- **Actions:**
|
||||||
|
1. Search for files following patterns: `[filename].test.ts`, `[filename].spec.js`, or within `__tests__` folders related to affected files.
|
||||||
|
2. Identify integration or E2E tests that cover the consumer paths identified in Stage 2.
|
||||||
|
- **Outputs:** A list of relevant test file paths.
|
||||||
|
- **Persistence Strategy:** Save findings to `/temp/tests.json`.
|
||||||
|
|
||||||
|
### Stage 4: Risk Hotspot Synthesis
|
||||||
|
- **Purpose:** Interpret raw dependency data into actionable risk warnings.
|
||||||
|
- **Inputs:** All files in `/temp/`.
|
||||||
|
- **Actions:**
|
||||||
|
1. Analyze the volume of consumers; if a file has >5 consumers, flag it as a "High Impact Hotspot."
|
||||||
|
2. Check for breaking contract changes (e.g., interface modifications) based on the "Acceptance Criteria".
|
||||||
|
3. Formulate specific "Risk Hotspot" warnings (e.g., "Changing Auth interface affects 12 files; consider a wrapper.").
|
||||||
|
- **Outputs:** A structured Markdown-ready report object.
|
||||||
|
- **Persistence Strategy:** Save final report data to `/temp/final_analysis.json`.
|
||||||
|
|
||||||
|
### Stage 5: Ticket Augmentation & Finalization
|
||||||
|
- **Purpose:** Update the physical ticket file with findings.
|
||||||
|
- **Inputs:** Original ticket file and `/temp/final_analysis.json`.
|
||||||
|
- **Actions:**
|
||||||
|
1. Read the current content of the ticket file.
|
||||||
|
2. Generate a Markdown section titled `## Impact Analysis (Generated: 2026-01-09)`.
|
||||||
|
3. Append the Direct Consumers, Test Coverage, and Risk Hotspots sections.
|
||||||
|
4. Write the combined content back to the original file path.
|
||||||
|
- **Outputs:** Updated Markdown ticket.
|
||||||
|
- **Persistence Strategy:** None (Final Action).
|
||||||
|
|
||||||
|
## 4. Data & File Contracts
|
||||||
|
- **State File (`/temp/state.json`):** - `affected_files`: string[]
|
||||||
|
- `consumers`: { path: string, context: string }[]
|
||||||
|
- `tests`: string[]
|
||||||
|
- `risks`: string[]
|
||||||
|
- **File Format:** All `/temp` files must be valid JSON.
|
||||||
|
- **Ticket Format:** Standard Markdown. Use `###` for sub-headers in the generated section.
|
||||||
|
|
||||||
|
## 5. Failure & Recovery Handling
|
||||||
|
- **Missing Ticket:** If the ticket path is invalid, exit immediately with error: "TICKET_NOT_FOUND".
|
||||||
|
- **Zero Consumers Found:** If no external consumers are found, state "No external dependencies detected" in the report; do not fail.
|
||||||
|
- **Broken Imports:** If AST parsing fails due to syntax errors in the codebase, fallback to `grep` for string-based matching.
|
||||||
|
- **Write Permission:** If the ticket file is read-only, output the final Markdown to the console and provide a warning.
|
||||||
|
|
||||||
|
## 6. Final Deliverable Specification
|
||||||
|
- **Format:** The original ticket file must be modified in-place.
|
||||||
|
- **Content:**
|
||||||
|
- **Direct Consumers:** Bulleted list of `[File Path]: [Usage description]`.
|
||||||
|
- **Test Coverage:** Bulleted list of `[File Path]`.
|
||||||
|
- **Risk Hotspots:** Clear, one-sentence warnings for high-risk areas.
|
||||||
|
- **Quality Bar:** No hallucinations. Every file path listed must exist in the repository. No deletions of original ticket content.
|
||||||
@@ -1,53 +1,72 @@
|
|||||||
---
|
---
|
||||||
description: Review the most recent changes critically.
|
description: Performs a high-intensity, "hostile" technical audit of the provided code.
|
||||||
---
|
---
|
||||||
|
|
||||||
### Role
|
# WORKFLOW: HOSTILE TECHNICAL AUDIT & SECURITY REVIEW
|
||||||
You are a Lead Security Engineer and Senior QA Automator. Your persona is **"The Hostile Reviewer."**
|
|
||||||
* **Mindset:** You do not trust the code. You assume it contains bugs, security flaws, and logic gaps.
|
|
||||||
* **Goal:** Your objective is to reject the most recent git changes by finding legitimate issues. If you cannot find issues, only then do you approve.
|
|
||||||
|
|
||||||
### Phase 1: The Security & Logic Audit
|
## 1. High-Level Goal
|
||||||
Analyze the code changes for specific vulnerabilities. Do not summarize what the code does; look for what it *does wrong*.
|
Execute a multi-pass, hyper-critical technical audit of provided source code to identify fatal logic flaws, security vulnerabilities, and architectural debt. The agent acts as a hostile reviewer with a "guilty until proven innocent" mindset, aiming to justify a REJECTED verdict unless the code demonstrates exceptional robustness and simplicity.
|
||||||
|
|
||||||
1. **TypeScript Strictness:**
|
## 2. Assumptions & Clarifications
|
||||||
* Flag any usage of `any`.
|
- **Assumption:** The user will provide either raw code snippets or paths to files within the agent's accessible environment.
|
||||||
* Flag any use of non-null assertions (`!`) unless strictly guarded.
|
- **Assumption:** The agent has access to `/temp/` for multi-stage state persistence.
|
||||||
* Flag forced type casting (`as UnknownType`) without validation.
|
- **Clarification:** If a "ticket description" or "requirement" is not provided, the agent will infer intent from the code but must flag "Lack of Context" as a potential risk.
|
||||||
2. **Bun/Runtime Specifics:**
|
- **Clarification:** "Hostile" refers to a rigorous, zero-tolerance standard, not unprofessional language.
|
||||||
* Check for unhandled Promises (floating promises).
|
|
||||||
* Ensure environment variables are not hardcoded.
|
|
||||||
3. **Security Vectors:**
|
|
||||||
* **Injection:** Check SQL/NoSQL queries for concatenation.
|
|
||||||
* **Sanitization:** Are inputs from the generic request body validated against the schema defined in the Ticket?
|
|
||||||
* **Auth:** Are sensitive routes actually protected by middleware?
|
|
||||||
|
|
||||||
### Phase 2: Test Quality Verification
|
## 3. Stage Breakdown
|
||||||
Do not just check if tests pass. Check if the tests are **valid**.
|
|
||||||
1. **The "Happy Path" Trap:** If the tests only check for success (status 200), **FAIL** the review.
|
|
||||||
2. **Edge Case Coverage:**
|
|
||||||
* Did the code handle the *Constraints & Validations* listed in the original ticket?
|
|
||||||
* *Example:* If the ticket says "Max 5MB upload", is there a test case for a 5.1MB file?
|
|
||||||
3. **Mocking Integrity:** Are mocks too permissive? (e.g., Mocking a function to always return `true` regardless of input).
|
|
||||||
|
|
||||||
### Phase 3: The Verdict
|
### Stage 1: Contextual Ingestion & Dependency Mapping
|
||||||
Output your review in the following strict format:
|
- **Purpose:** Map the attack surface and understand the logical flow before the audit.
|
||||||
|
- **Inputs:** Target source code files.
|
||||||
|
- **Actions:** - Identify all external dependencies and entry points.
|
||||||
|
- Map data flow from input to storage/output.
|
||||||
|
- Identify "High-Risk Zones" (e.g., auth logic, DB queries, memory management).
|
||||||
|
- **Outputs:** A structured map of the code's architecture.
|
||||||
|
- **Persistence Strategy:** Save `audit_map.json` to `/temp/` containing the file list and identified High-Risk Zones.
|
||||||
|
|
||||||
---
|
### Stage 2: Security & Logic Stress Test (The "Hostile" Pass)
|
||||||
# 🛡️ Code Review Report
|
- **Purpose:** Identify reasons to reject the code based on security and logical integrity.
|
||||||
|
- **Inputs:** `/temp/audit_map.json` and source code.
|
||||||
|
- **Actions:**
|
||||||
|
- Scan for injection, race conditions, and improper state handling.
|
||||||
|
- Simulate edge cases: null inputs, buffer overflows, and malformed data.
|
||||||
|
- Evaluate "Silent Failures": Does the code swallow exceptions or fail to log critical errors?
|
||||||
|
- **Outputs:** List of fatal flaws and security risks.
|
||||||
|
- **Persistence Strategy:** Save `vulnerabilities.json` to `/temp/`.
|
||||||
|
|
||||||
**Ticket ID:** [Ticket Name]
|
### Stage 3: Performance & Velocity Debt Assessment
|
||||||
**Verdict:** [🔴 REJECT / 🟢 APPROVE]
|
- **Purpose:** Evaluate the "Pragmatic Performance" and maintainability of the implementation.
|
||||||
|
- **Inputs:** Source code and `/temp/vulnerabilities.json`.
|
||||||
|
- **Actions:**
|
||||||
|
- Identify redundant API calls or unnecessary allocations.
|
||||||
|
- Flag "Over-Engineering" (unnecessary abstractions) vs. "Lazy Code" (hardcoded values).
|
||||||
|
- Identify missing unit test scenarios for identified edge cases.
|
||||||
|
- **Outputs:** List of optimization debt and missing test scenarios.
|
||||||
|
- **Persistence Strategy:** Save `debt_and_tests.json` to `/temp/`.
|
||||||
|
|
||||||
## 🚨 Critical Issues (Must Fix)
|
### Stage 4: Synthesis & Verdict Generation
|
||||||
*List logic bugs, security risks, or failing tests.*
|
- **Purpose:** Compile all findings into the final "Hostile Audit" report.
|
||||||
1. ...
|
- **Inputs:** `/temp/vulnerabilities.json` and `/temp/debt_and_tests.json`.
|
||||||
2. ...
|
- **Actions:**
|
||||||
|
- Consolidate all findings into the mandated "Response Format."
|
||||||
|
- Apply the "Burden of Proof" rule: If any Fatal Flaws or Security Risks exist, the verdict is REJECTED.
|
||||||
|
- Ensure no sycophantic language is present.
|
||||||
|
- **Outputs:** Final Audit Report.
|
||||||
|
- **Persistence Strategy:** Final output is delivered to the user; `/temp/` files may be purged.
|
||||||
|
|
||||||
## ⚠️ Suggestions (Refactoring)
|
## 4. Data & File Contracts
|
||||||
*List code style improvements, variable naming, or DRY opportunities.*
|
- **Filename:** `/temp/audit_context.json` | **Schema:** `{ "high_risk_zones": [], "entry_points": [] }`
|
||||||
1. ...
|
- **Filename:** `/temp/findings.json` | **Schema:** `{ "fatal_flaws": [], "security_risks": [], "debt": [], "missing_tests": [] }`
|
||||||
|
- **Final Report Format:** Markdown with specific headers: `## 🛑 FATAL FLAWS`, `## ⚠️ SECURITY & VULNERABILITIES`, `## 📉 VELOCITY DEBT`, `## 🧪 MISSING TESTS`, and `### VERDICT`.
|
||||||
|
|
||||||
## 🧪 Test Coverage Gap Analysis
|
## 5. Failure & Recovery Handling
|
||||||
*List specific scenarios that are NOT currently tested but should be.*
|
- **Incomplete Input:** If the code is snippet-based and missing context, the agent must assume the worst-case scenario for the missing parts and flag them as "Critical Unknowns."
|
||||||
- [ ] Scenario: ...
|
- **Stage Failure:** If a specific file cannot be parsed, log the error in the `findings.json` and proceed with the remaining files.
|
||||||
|
- **Clarification:** The agent will NOT ask for clarification mid-audit. It will make a "hostile assumption" and document it as a risk.
|
||||||
|
|
||||||
|
## 6. Final Deliverable Specification
|
||||||
|
- **Tone:** Senior Security Auditor. Clinical, critical, and direct.
|
||||||
|
- **Acceptance Criteria:** - No "Good job" or introductory filler.
|
||||||
|
- Every flaw must include [Why it fails] and [How to fix it].
|
||||||
|
- Verdict must be REJECTED unless the code is "solid" (simple, robust, and secure).
|
||||||
|
- Must identify at least one specific edge case for the "Missing Tests" section.
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
description: Pick a Ticket and work on it.
|
|
||||||
---
|
|
||||||
|
|
||||||
### Role
|
|
||||||
You are an Autonomous Senior Software Engineer specializing in TypeScript and Bun. You are responsible for the full lifecycle of feature implementation: selection, coding, testing, verification, and closure.
|
|
||||||
|
|
||||||
|
|
||||||
### Phase 1: Triage & Selection
|
|
||||||
1. **Scan:** Read all files in the `/tickets` directory.
|
|
||||||
2. **Filter:** Ignore tickets marked `Status: Done` or `Status: Archived`.
|
|
||||||
3. **Prioritize:** Select a single ticket based on the following hierarchy:
|
|
||||||
* **Tags:** `Critical` > `High Priority` > `Bug` > `Feature`.
|
|
||||||
* **Age:** Oldest created date first (FIFO).
|
|
||||||
4. **Announce:** Explicitly state: "I am picking ticket: [Ticket ID/Name] because [Reason]."
|
|
||||||
|
|
||||||
### Phase 2: Setup (Non-Destructive)
|
|
||||||
1. **Branching:** Create a new git branch based on the ticket name.
|
|
||||||
* *Format:* `feat/{ticket-kebab-name}` or `fix/{ticket-kebab-name}`.
|
|
||||||
* *Command:* `git checkout -b feat/user-auth-flow`.
|
|
||||||
2. **Context:** Read the selected ticket markdown file thoroughly, paying special attention to "Constraints & Validations."
|
|
||||||
|
|
||||||
### Phase 3: Implementation & Testing (The Loop)
|
|
||||||
*Iterate until the requirements are met.*
|
|
||||||
|
|
||||||
1. **Write Code:** Implement the feature or fix using TypeScript.
|
|
||||||
2. **Tightened Testing:**
|
|
||||||
* You must create or update test files (`*.test.ts` or `*.spec.ts`).
|
|
||||||
* **Requirement:** Tests must cover happy paths AND the edge cases defined in the ticket's "Constraints" section.
|
|
||||||
* *Mocking:* Mock external dependencies where appropriate to ensure isolation.
|
|
||||||
3. **Type Safety Check:**
|
|
||||||
* Run: `bun x tsc --noEmit`
|
|
||||||
* **CRITICAL:** If there are ANY TypeScript errors, you must fix them immediately. Do not proceed.
|
|
||||||
4. **Runtime Verification:**
|
|
||||||
* Run: `bun test`
|
|
||||||
* Ensure all tests pass. If a test fails, analyze the stack trace, fix the implementation, and rerun.
|
|
||||||
|
|
||||||
### Phase 4: Self-Review & Clean Up
|
|
||||||
Before declaring the task finished, perform a self-review:
|
|
||||||
1. **Linting:** Check for unused variables, any types, or console logs.
|
|
||||||
2. **Refactor:** Ensure code is DRY (Don't Repeat Yourself) and strictly typed.
|
|
||||||
3. **Ticket Update:**
|
|
||||||
* Modify the Markdown ticket file.
|
|
||||||
* Change `Status: Draft` to `Status: In Review` or `Status: Done`.
|
|
||||||
* Add a new section at the bottom: `## Implementation Notes` listing the specific files changed.
|
|
||||||
|
|
||||||
### Phase 5: Handover
|
|
||||||
Only when `bun x tsc` and `bun test` pass with 0 errors:
|
|
||||||
1. Commit the changes with a semantic message (e.g., `feat: implement user auth logic`).
|
|
||||||
2. Present a summary of the work done and ask for a human code review.
|
|
||||||
99
.agent/workflows/work.md
Normal file
99
.agent/workflows/work.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
description: Work on a ticket
|
||||||
|
---
|
||||||
|
|
||||||
|
# WORKFLOW: Automated Feature Implementation and Review Cycle
|
||||||
|
|
||||||
|
## 1. High-Level Goal
|
||||||
|
The objective of this workflow is to autonomously ingest a task from a local `/tickets` directory, establish a dedicated development environment via Git branching, implement the requested changes with incremental commits, validate the work through an internal review process, and finalize the lifecycle by cleaning up ticket artifacts and seeking user authorization for the final merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Assumptions & Clarifications
|
||||||
|
- **Assumptions:**
|
||||||
|
- The `/tickets` directory contains one or more files representing tasks (e.g., `.md` or `.txt`).
|
||||||
|
- The agent has authenticated access to the local Git repository.
|
||||||
|
- A "Review Workflow" exists as an executable command or internal process.
|
||||||
|
- The branch naming convention is `feature/[ticket-filename-slug]`.
|
||||||
|
- **Ambiguities:**
|
||||||
|
- If multiple tickets exist, the agent will select the one with the earliest "Last Modified" timestamp.
|
||||||
|
- "Regular commits" are defined as committing after every logically complete file change or functional milestone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stage Breakdown
|
||||||
|
|
||||||
|
### Stage 1: Ticket Selection and Branch Initialization
|
||||||
|
- **Purpose:** Identify the next task and prepare the workspace.
|
||||||
|
- **Inputs:** Contents of the `/tickets` directory.
|
||||||
|
- **Actions:**
|
||||||
|
1. Scan `/tickets` and select the oldest file.
|
||||||
|
2. Parse the ticket content to understand requirements.
|
||||||
|
3. Ensure the current working directory is a Git repository.
|
||||||
|
4. Create and switch to a new branch: `feature/[ticket-id]`.
|
||||||
|
- **Outputs:** Active feature branch.
|
||||||
|
- **Persistence Strategy:** Save `state.json` to `/temp` containing `ticket_path`, `branch_name`, and `start_time`.
|
||||||
|
|
||||||
|
### Stage 2: Implementation and Incremental Committing
|
||||||
|
- **Purpose:** Execute the technical requirements of the ticket.
|
||||||
|
- **Inputs:** `/temp/state.json`, Ticket requirements.
|
||||||
|
- **Actions:**
|
||||||
|
1. Modify codebase according to requirements.
|
||||||
|
2. For every distinct file change or logical unit of work:
|
||||||
|
- Run basic syntax checks.
|
||||||
|
- Execute `git add [file]`.
|
||||||
|
- Execute `git commit -m "feat: [brief description of change]"`
|
||||||
|
3. Repeat until the feature is complete.
|
||||||
|
- **Outputs:** Committed code changes on the feature branch.
|
||||||
|
- **Persistence Strategy:** Update `state.json` with `implementation_complete: true` and a list of `modified_files`.
|
||||||
|
|
||||||
|
### Stage 3: Review Workflow Execution
|
||||||
|
- **Purpose:** Validate the implementation against quality standards.
|
||||||
|
- **Inputs:** `/temp/state.json`, Modified codebase.
|
||||||
|
- **Actions:**
|
||||||
|
1. Trigger the "Review Workflow" (static analysis, tests, or linter).
|
||||||
|
2. If errors are found:
|
||||||
|
- Log errors to `/temp/review_log.txt`.
|
||||||
|
- Re-enter Stage 2 to apply fixes and commit.
|
||||||
|
3. If review passes:
|
||||||
|
- Proceed to Stage 4.
|
||||||
|
- **Outputs:** Review results/logs.
|
||||||
|
- **Persistence Strategy:** Update `state.json` with `review_passed: true`.
|
||||||
|
|
||||||
|
### Stage 4: Cleanup and User Handoff
|
||||||
|
- **Purpose:** Finalize the ticket lifecycle and request merge permission.
|
||||||
|
- **Inputs:** `/temp/state.json`.
|
||||||
|
- **Actions:**
|
||||||
|
1. Delete the ticket file from `/tickets` using the path stored in `state.json`.
|
||||||
|
2. Format a summary of changes and a request for merge.
|
||||||
|
- **Outputs:** Deletion of the ticket file; user-facing summary.
|
||||||
|
- **Persistence Strategy:** Clear `/temp/state.json` upon successful completion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data & File Contracts
|
||||||
|
- **State File:** `/temp/state.json`
|
||||||
|
- Format: JSON
|
||||||
|
- Schema: `{ "ticket_path": string, "branch_name": string, "implementation_complete": boolean, "review_passed": boolean }`
|
||||||
|
- **Ticket Files:** Located in `/tickets/*` (Markdown or Plain Text).
|
||||||
|
- **Logs:** `/temp/review_log.txt` (Plain Text) for capturing stderr from review tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Failure & Recovery Handling
|
||||||
|
- **Empty Ticket Directory:** If no files are found in `/tickets`, the agent will output "NO_TICKETS_FOUND" and terminate the workflow.
|
||||||
|
- **Commit Failures:** If a commit fails (e.g., pre-commit hooks), the agent must resolve the hook violation before retrying the commit.
|
||||||
|
- **Review Failure Loop:** If the review fails more than 3 times for the same issue, the agent must halt and output a "BLOCKER_REPORT" detailing the persistent errors to the user.
|
||||||
|
- **State Recovery:** On context reset, the agent must check `/temp/state.json` to resume the workflow from the last recorded stage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Final Deliverable Specification
|
||||||
|
- **Final Output:** A clear message to the user in the following format:
|
||||||
|
> **Task Completed:** [Ticket Name]
|
||||||
|
> **Branch:** [Branch Name]
|
||||||
|
> **Changes:** [Brief list of modified files]
|
||||||
|
> **Review Status:** Passed
|
||||||
|
> **Cleanup:** Ticket file removed from /tickets.
|
||||||
|
> **Action Required:** Would you like me to merge [Branch Name] into `main`? (Yes/No)
|
||||||
|
- **Quality Bar:** Code must be committed with descriptive messages; the ticket file must be successfully deleted; the workspace must be left on the feature branch awaiting the merge command.
|
||||||
67
README.md
67
README.md
@@ -7,24 +7,44 @@
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
|
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
|
||||||
|
|
||||||
|
**New in v1.0:** Aurora now includes a fully integrated **Web Dashboard** for managing the bot, viewing statistics, and configuring settings, running alongside the bot in a single process.
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
|
### Discord Bot
|
||||||
* **Class System**: Users can join different classes.
|
* **Class System**: Users can join different classes.
|
||||||
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
||||||
* **Inventory & Items**: sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
||||||
* **Leveling**: XP-based leveling system to track user activity and progress.
|
* **Leveling**: XP-based leveling system to track user activity and progress.
|
||||||
* **Quests**: Quest system with requirements and rewards.
|
* **Quests**: Quest system with requirements and rewards.
|
||||||
* **Trading**: Secure trading system between users.
|
* **Trading**: Secure trading system between users.
|
||||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||||
* **Admin Tools**: Administrative commands for server management.
|
* **Admin Tools**: Administrative commands for server management.
|
||||||
|
|
||||||
|
### Web Dashboard
|
||||||
|
* **Live Analytics**: View real-time activity charts (commands, transactions).
|
||||||
|
* **Configuration Management**: Update bot settings without restarting.
|
||||||
|
* **Database Inspection**: Integrated Drizzle Studio access.
|
||||||
|
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
|
||||||
|
|
||||||
|
* **Unified Runtime**: Both the Discord Client and the Web Dashboard run within the same Bun process.
|
||||||
|
* **Shared State**: This allows the Dashboard to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
|
||||||
|
* **Simplified Deployment**: You only need to deploy a single Docker container.
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
* **Runtime**: [Bun](https://bun.sh/)
|
* **Runtime**: [Bun](https://bun.sh/)
|
||||||
* **Framework**: [Discord.js](https://discord.js.org/)
|
* **Bot Framework**: [Discord.js](https://discord.js.org/)
|
||||||
|
* **Web Framework**: [React 19](https://react.dev/) + [Vite](https://vitejs.dev/) (served via Bun)
|
||||||
|
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/)
|
||||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||||
* **Validation**: [Zod](https://zod.dev/)
|
* **Validation**: [Zod](https://zod.dev/)
|
||||||
@@ -74,12 +94,14 @@ Aurora is a powerful Discord bot designed to facilitate RPG-like elements within
|
|||||||
bun run db:push
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the Bot
|
### Running the Bot & Dashboard
|
||||||
|
|
||||||
**Development Mode** (with hot reload):
|
**Development Mode** (with hot reload):
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
* Bot: Online in Discord
|
||||||
|
* Dashboard: http://localhost:3000
|
||||||
|
|
||||||
**Production Mode**:
|
**Production Mode**:
|
||||||
Build and run with Docker (recommended):
|
Build and run with Docker (recommended):
|
||||||
@@ -87,27 +109,46 @@ Build and run with Docker (recommended):
|
|||||||
docker compose up -d app
|
docker compose up -d app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 🔐 Accessing Production Services (SSH Tunnel)
|
||||||
|
|
||||||
|
For security, the Production Database and Dashboard are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
|
||||||
|
|
||||||
|
To access them from your local machine, use the included SSH tunnel script.
|
||||||
|
|
||||||
|
1. Add your VPS details to your local `.env` file:
|
||||||
|
```env
|
||||||
|
VPS_USER=root
|
||||||
|
VPS_HOST=123.45.67.89
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the remote connection script:
|
||||||
|
```bash
|
||||||
|
bun run remote
|
||||||
|
```
|
||||||
|
|
||||||
|
This will establish secure tunnels for:
|
||||||
|
* **Dashboard**: http://localhost:3000
|
||||||
|
* **Drizzle Studio**: http://localhost:4983
|
||||||
|
|
||||||
## 📜 Scripts
|
## 📜 Scripts
|
||||||
|
|
||||||
* `bun run dev`: Start the bot in watch mode.
|
* `bun run dev`: Start the bot and dashboard in watch mode.
|
||||||
|
* `bun run remote`: Open SSH tunnel to production services.
|
||||||
* `bun run generate`: Generate Drizzle migrations.
|
* `bun run generate`: Generate Drizzle migrations.
|
||||||
* `bun run migrate`: Apply migrations (via Docker).
|
* `bun run migrate`: Apply migrations (via Docker).
|
||||||
* `bun run db:push`: Push, schema to DB (via Docker).
|
|
||||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||||
* `bun test`: Run tests.
|
* `bun test`: Run tests.
|
||||||
|
|
||||||
## 📂 Project Structure
|
## 📂 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── src
|
├── bot # Discord Bot logic & entry point
|
||||||
│ ├── commands # Slash commands
|
├── web # React Web Dashboard (Frontend + Server)
|
||||||
│ ├── events # Discord event handlers
|
├── shared # Shared code (Database, Config, Types)
|
||||||
│ ├── modules # Feature modules (Economy, Inventory, etc.)
|
|
||||||
│ ├── db # Database schema and connection
|
|
||||||
│ └── lib # Shared utilities
|
|
||||||
├── drizzle # Drizzle migration files
|
├── drizzle # Drizzle migration files
|
||||||
├── config # Configuration files
|
├── scripts # Utility scripts
|
||||||
└── scripts # Utility scripts
|
├── docker-compose.yml
|
||||||
|
└── package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|||||||
117
bot/commands/economy/trivia.ts
Normal file
117
bot/commands/economy/trivia.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||||
|
import { getTriviaQuestionView } from "@/modules/trivia/trivia.view";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { UserError } from "@/lib/errors";
|
||||||
|
import { config } from "@shared/lib/config";
|
||||||
|
import { TriviaCategory } from "@shared/lib/constants";
|
||||||
|
|
||||||
|
export const trivia = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("trivia")
|
||||||
|
.setDescription("Play trivia to win currency! Answer correctly within the time limit.")
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('category')
|
||||||
|
.setDescription('Select a specific category')
|
||||||
|
.setRequired(false)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'General Knowledge', value: String(TriviaCategory.GENERAL_KNOWLEDGE) },
|
||||||
|
{ name: 'Books', value: String(TriviaCategory.BOOKS) },
|
||||||
|
{ name: 'Film', value: String(TriviaCategory.FILM) },
|
||||||
|
{ name: 'Music', value: String(TriviaCategory.MUSIC) },
|
||||||
|
{ name: 'Video Games', value: String(TriviaCategory.VIDEO_GAMES) },
|
||||||
|
{ name: 'Science & Nature', value: String(TriviaCategory.SCIENCE_NATURE) },
|
||||||
|
{ name: 'Computers', value: String(TriviaCategory.COMPUTERS) },
|
||||||
|
{ name: 'Mathematics', value: String(TriviaCategory.MATHEMATICS) },
|
||||||
|
{ name: 'Mythology', value: String(TriviaCategory.MYTHOLOGY) },
|
||||||
|
{ name: 'Sports', value: String(TriviaCategory.SPORTS) },
|
||||||
|
{ name: 'Geography', value: String(TriviaCategory.GEOGRAPHY) },
|
||||||
|
{ name: 'History', value: String(TriviaCategory.HISTORY) },
|
||||||
|
{ name: 'Politics', value: String(TriviaCategory.POLITICS) },
|
||||||
|
{ name: 'Art', value: String(TriviaCategory.ART) },
|
||||||
|
{ name: 'Animals', value: String(TriviaCategory.ANIMALS) },
|
||||||
|
{ name: 'Anime & Manga', value: String(TriviaCategory.ANIME_MANGA) },
|
||||||
|
)
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
try {
|
||||||
|
const categoryId = interaction.options.getString('category');
|
||||||
|
|
||||||
|
// Check if user can play BEFORE deferring
|
||||||
|
const canPlay = await triviaService.canPlayTrivia(interaction.user.id);
|
||||||
|
|
||||||
|
if (!canPlay.canPlay) {
|
||||||
|
// Cooldown error - ephemeral
|
||||||
|
const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000);
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You're on cooldown! Try again <t:${timestamp}:R>.`
|
||||||
|
)],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User can play - defer publicly for trivia question
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
// Start trivia session (deducts entry fee)
|
||||||
|
const session = await triviaService.startTrivia(
|
||||||
|
interaction.user.id,
|
||||||
|
interaction.user.username,
|
||||||
|
categoryId ? parseInt(categoryId) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate Components v2 message
|
||||||
|
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
|
||||||
|
|
||||||
|
// Reply with Components v2 question
|
||||||
|
await interaction.editReply({
|
||||||
|
components,
|
||||||
|
flags
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up automatic timeout cleanup
|
||||||
|
setTimeout(async () => {
|
||||||
|
const stillActive = triviaService.getSession(session.sessionId);
|
||||||
|
if (stillActive) {
|
||||||
|
// User didn't answer - clean up session with no reward
|
||||||
|
try {
|
||||||
|
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
|
||||||
|
} catch (error) {
|
||||||
|
// Session already cleaned up, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
// Check if we've already deferred
|
||||||
|
if (interaction.deferred) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(error.message)]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Error in trivia command:", error);
|
||||||
|
// Check if we've already deferred
|
||||||
|
if (interaction.deferred) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -37,6 +37,11 @@ export const interactionRoutes: InteractionRoute[] = [
|
|||||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||||
method: 'handleLootdropInteraction'
|
method: 'handleLootdropInteraction'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
|
||||||
|
handler: () => import("@/modules/trivia/trivia.interaction"),
|
||||||
|
method: 'handleTriviaInteraction'
|
||||||
|
},
|
||||||
|
|
||||||
// --- ADMIN MODULE ---
|
// --- ADMIN MODULE ---
|
||||||
{
|
{
|
||||||
|
|||||||
116
bot/modules/trivia/README.md
Normal file
116
bot/modules/trivia/README.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Trivia - Components v2 Implementation
|
||||||
|
|
||||||
|
This trivia feature uses **Discord Components v2** for a premium visual experience.
|
||||||
|
|
||||||
|
## 🎨 Visual Features
|
||||||
|
|
||||||
|
### **Container with Accent Colors**
|
||||||
|
Each trivia question is displayed in a Container with a colored accent bar that changes based on difficulty:
|
||||||
|
- **🟢 Easy**: Green accent bar (`0x57F287`)
|
||||||
|
- **🟡 Medium**: Yellow accent bar (`0xFEE75C`)
|
||||||
|
- **🔴 Hard**: Red accent bar (`0xED4245`)
|
||||||
|
|
||||||
|
### **Modern Layout Components**
|
||||||
|
- **TextDisplay** - Rich markdown formatting for question text
|
||||||
|
- **Separator** - Visual spacing between sections
|
||||||
|
- **Container** - Groups all content with difficulty-based styling
|
||||||
|
|
||||||
|
### **Interactive Features**
|
||||||
|
✅ **Give Up Button** - Players can forfeit if they're unsure
|
||||||
|
✅ **Disabled Answer Buttons** - After answering, buttons show:
|
||||||
|
- ✅ Green for correct answer
|
||||||
|
- ❌ Red for user's incorrect answer
|
||||||
|
- Gray for other options
|
||||||
|
|
||||||
|
✅ **Time Display** - Shows both relative time (`in 30s`) and seconds remaining
|
||||||
|
✅ **Stakes Preview** - Clear display: `50 AU ➜ 100 AU`
|
||||||
|
|
||||||
|
## 📁 File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bot/modules/trivia/
|
||||||
|
├── trivia.view.ts # Components v2 view functions
|
||||||
|
├── trivia.interaction.ts # Button interaction handler
|
||||||
|
└── README.md # This file
|
||||||
|
|
||||||
|
bot/commands/economy/
|
||||||
|
└── trivia.ts # /trivia slash command
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Technical Details
|
||||||
|
|
||||||
|
### Components v2 Requirements
|
||||||
|
- Uses `MessageFlags.IsComponentsV2` flag
|
||||||
|
- No `embeds` or `content` fields (uses TextDisplay instead)
|
||||||
|
- Numeric component types:
|
||||||
|
- `1` - Action Row
|
||||||
|
- `2` - Button
|
||||||
|
- `10` - Text Display
|
||||||
|
- `14` - Separator
|
||||||
|
- `17` - Container
|
||||||
|
- Max 40 components per message (vs 5 for legacy)
|
||||||
|
|
||||||
|
### Button Styles
|
||||||
|
- **Secondary (2)**: Gray - Used for answer buttons
|
||||||
|
- **Success (3)**: Green - Used for "True" and correct answers
|
||||||
|
- **Danger (4)**: Red - Used for "False", incorrect answers, and "Give Up"
|
||||||
|
|
||||||
|
## 🎮 User Experience Flow
|
||||||
|
|
||||||
|
1. User runs `/trivia`
|
||||||
|
2. Sees question in a Container with difficulty-based accent color
|
||||||
|
3. Can choose to:
|
||||||
|
- Select an answer (A/B/C/D or True/False)
|
||||||
|
- Give up using the 🏳️ button
|
||||||
|
4. After answering, sees result with:
|
||||||
|
- Disabled buttons showing correct/incorrect answers
|
||||||
|
- Container with result-based accent color (green/red/yellow)
|
||||||
|
- Reward or penalty information
|
||||||
|
|
||||||
|
## 🌟 Visual Examples
|
||||||
|
|
||||||
|
### Question Display
|
||||||
|
```
|
||||||
|
┌─[GREEN]─────────────────────────┐
|
||||||
|
│ # 🎯 Trivia Challenge │
|
||||||
|
│ 🟢 Easy • 📚 Geography │
|
||||||
|
│ ─────────────────────────── │
|
||||||
|
│ ### What is the capital of │
|
||||||
|
│ France? │
|
||||||
|
│ │
|
||||||
|
│ ⏱️ Time: in 30s (30s) │
|
||||||
|
│ 💰 Stakes: 50 AU ➜ 100 AU │
|
||||||
|
│ 👤 Player: Username │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
[🇦 A: Paris] [🇧 B: London]
|
||||||
|
[🇨 C: Berlin] [🇩 D: Madrid]
|
||||||
|
[🏳️ Give Up]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Result Display (Correct)
|
||||||
|
```
|
||||||
|
┌─[GREEN]─────────────────────────┐
|
||||||
|
│ # 🎉 Correct Answer! │
|
||||||
|
│ ### What is the capital of │
|
||||||
|
│ France? │
|
||||||
|
│ ─────────────────────────── │
|
||||||
|
│ ✅ Your answer: Paris │
|
||||||
|
│ │
|
||||||
|
│ 💰 Reward: +100 AU │
|
||||||
|
│ │
|
||||||
|
│ 🏆 Great job! Keep it up! │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
[✅ A: Paris] [❌ B: London]
|
||||||
|
[❌ C: Berlin] [❌ D: Madrid]
|
||||||
|
(all buttons disabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements:
|
||||||
|
- [ ] Thumbnail images based on trivia category
|
||||||
|
- [ ] Progress bar for time remaining
|
||||||
|
- [ ] Streak counter display
|
||||||
|
- [ ] Category-specific accent colors
|
||||||
|
- [ ] Media Gallery for image-based questions
|
||||||
|
- [ ] Leaderboard integration in results
|
||||||
129
bot/modules/trivia/trivia.interaction.ts
Normal file
129
bot/modules/trivia/trivia.interaction.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { ButtonInteraction } from "discord.js";
|
||||||
|
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||||
|
import { getTriviaResultView, getTriviaTimeoutView } from "./trivia.view";
|
||||||
|
import { UserError } from "@/lib/errors";
|
||||||
|
|
||||||
|
export async function handleTriviaInteraction(interaction: ButtonInteraction) {
|
||||||
|
const parts = interaction.customId.split('_');
|
||||||
|
|
||||||
|
// Check for "Give Up" button
|
||||||
|
if (parts.length >= 3 && parts[0] === 'trivia' && parts[1] === 'giveup') {
|
||||||
|
const sessionId = `${parts[2]}_${parts[3]}`;
|
||||||
|
const session = triviaService.getSession(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: '❌ This trivia question has expired or already been answered.',
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ownership
|
||||||
|
if (session.userId !== interaction.user.id) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: '❌ This isn\'t your trivia question!',
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
|
// Process as incorrect (user gave up)
|
||||||
|
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, false);
|
||||||
|
|
||||||
|
// Show timeout view (since they gave up)
|
||||||
|
const { components, flags } = getTriviaTimeoutView(
|
||||||
|
session.question.question,
|
||||||
|
session.question.correctAnswer,
|
||||||
|
session.allAnswers,
|
||||||
|
session.entryFee
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
components,
|
||||||
|
flags
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle answer button
|
||||||
|
if (parts.length < 5 || parts[0] !== 'trivia' || parts[1] !== 'answer') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = `${parts[2]}_${parts[3]}`;
|
||||||
|
const answerIndexStr = parts[4];
|
||||||
|
|
||||||
|
if (!answerIndexStr) {
|
||||||
|
throw new UserError('Invalid answer format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerIndex = parseInt(answerIndexStr);
|
||||||
|
|
||||||
|
// Get session BEFORE deferring to check ownership
|
||||||
|
const session = triviaService.getSession(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
// Session doesn't exist or expired
|
||||||
|
await interaction.reply({
|
||||||
|
content: '❌ This trivia question has expired or already been answered.',
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ownership BEFORE deferring
|
||||||
|
if (session.userId !== interaction.user.id) {
|
||||||
|
// Wrong user trying to answer - send ephemeral error
|
||||||
|
await interaction.reply({
|
||||||
|
content: '❌ This isn\'t your trivia question! Use `/trivia` to start your own game.',
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only defer if ownership is valid
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
|
// Check timeout
|
||||||
|
if (new Date() > session.expiresAt) {
|
||||||
|
const { components, flags } = getTriviaTimeoutView(
|
||||||
|
session.question.question,
|
||||||
|
session.question.correctAnswer,
|
||||||
|
session.allAnswers,
|
||||||
|
session.entryFee
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
components,
|
||||||
|
flags
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up session
|
||||||
|
await triviaService.submitAnswer(sessionId, interaction.user.id, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if correct
|
||||||
|
const isCorrect = answerIndex === session.correctIndex;
|
||||||
|
const userAnswer = session.allAnswers[answerIndex];
|
||||||
|
|
||||||
|
// Process result
|
||||||
|
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, isCorrect);
|
||||||
|
|
||||||
|
// Update message with enhanced visual feedback
|
||||||
|
const { components, flags } = getTriviaResultView(
|
||||||
|
result,
|
||||||
|
session.question.question,
|
||||||
|
userAnswer,
|
||||||
|
session.allAnswers,
|
||||||
|
session.entryFee
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
components,
|
||||||
|
flags
|
||||||
|
});
|
||||||
|
}
|
||||||
336
bot/modules/trivia/trivia.view.ts
Normal file
336
bot/modules/trivia/trivia.view.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import { MessageFlags } from "discord.js";
|
||||||
|
import type { TriviaSession, TriviaResult } from "@shared/modules/trivia/trivia.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color based on difficulty level
|
||||||
|
*/
|
||||||
|
function getDifficultyColor(difficulty: string): number {
|
||||||
|
switch (difficulty.toLowerCase()) {
|
||||||
|
case 'easy':
|
||||||
|
return 0x57F287; // Green
|
||||||
|
case 'medium':
|
||||||
|
return 0xFEE75C; // Yellow
|
||||||
|
case 'hard':
|
||||||
|
return 0xED4245; // Red
|
||||||
|
default:
|
||||||
|
return 0x5865F2; // Blurple
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get emoji for difficulty level
|
||||||
|
*/
|
||||||
|
function getDifficultyEmoji(difficulty: string): string {
|
||||||
|
switch (difficulty.toLowerCase()) {
|
||||||
|
case 'easy':
|
||||||
|
return '🟢';
|
||||||
|
case 'medium':
|
||||||
|
return '🟡';
|
||||||
|
case 'hard':
|
||||||
|
return '🔴';
|
||||||
|
default:
|
||||||
|
return '⭐';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Components v2 message for a trivia question
|
||||||
|
*/
|
||||||
|
export function getTriviaQuestionView(session: TriviaSession, username: string): {
|
||||||
|
components: any[];
|
||||||
|
flags: number;
|
||||||
|
} {
|
||||||
|
const { question, allAnswers, entryFee, potentialReward, expiresAt, sessionId } = session;
|
||||||
|
|
||||||
|
// Calculate time remaining
|
||||||
|
const now = Date.now();
|
||||||
|
const timeLeft = Math.max(0, expiresAt.getTime() - now);
|
||||||
|
const secondsLeft = Math.floor(timeLeft / 1000);
|
||||||
|
|
||||||
|
const difficultyEmoji = getDifficultyEmoji(question.difficulty);
|
||||||
|
const difficultyText = question.difficulty.charAt(0).toUpperCase() + question.difficulty.slice(1);
|
||||||
|
const accentColor = getDifficultyColor(question.difficulty);
|
||||||
|
|
||||||
|
const components: any[] = [];
|
||||||
|
|
||||||
|
// Main Container with difficulty accent color
|
||||||
|
components.push({
|
||||||
|
type: 17, // Container
|
||||||
|
accent_color: accentColor,
|
||||||
|
components: [
|
||||||
|
// Title and metadata section
|
||||||
|
{
|
||||||
|
type: 10, // Text Display
|
||||||
|
content: `# 🎯 Trivia Challenge\n**${difficultyEmoji} ${difficultyText}** • 📚 ${question.category}`
|
||||||
|
},
|
||||||
|
// Separator
|
||||||
|
{
|
||||||
|
type: 14, // Separator
|
||||||
|
spacing: 1,
|
||||||
|
divider: true
|
||||||
|
},
|
||||||
|
// Question
|
||||||
|
{
|
||||||
|
type: 10, // Text Display
|
||||||
|
content: `### ${question.question}`
|
||||||
|
},
|
||||||
|
// Stats section
|
||||||
|
{
|
||||||
|
type: 14, // Separator
|
||||||
|
spacing: 1,
|
||||||
|
divider: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 10, // Text Display
|
||||||
|
content: `⏱️ **Time:** <t:${Math.floor(expiresAt.getTime() / 1000)}:R> (${secondsLeft}s)\n💰 **Stakes:** ${entryFee} AU ➜ ${potentialReward} AU\n👤 **Player:** ${username}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Answer buttons
|
||||||
|
if (question.type === 'boolean') {
|
||||||
|
const trueIndex = allAnswers.indexOf('True');
|
||||||
|
const falseIndex = allAnswers.indexOf('False');
|
||||||
|
|
||||||
|
components.push({
|
||||||
|
type: 1, // Action Row
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 2, // Button
|
||||||
|
custom_id: `trivia_answer_${sessionId}_${trueIndex}`,
|
||||||
|
label: 'True',
|
||||||
|
style: 3, // Success
|
||||||
|
emoji: { name: '✅' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 2, // Button
|
||||||
|
custom_id: `trivia_answer_${sessionId}_${falseIndex}`,
|
||||||
|
label: 'False',
|
||||||
|
style: 4, // Danger
|
||||||
|
emoji: { name: '❌' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const labels = ['A', 'B', 'C', 'D'];
|
||||||
|
const emojis = ['🇦', '🇧', '🇨', '🇩'];
|
||||||
|
|
||||||
|
const buttonRow: any = {
|
||||||
|
type: 1, // Action Row
|
||||||
|
components: []
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < allAnswers.length && i < 4; i++) {
|
||||||
|
const label = labels[i];
|
||||||
|
const emoji = emojis[i];
|
||||||
|
const answer = allAnswers[i];
|
||||||
|
|
||||||
|
if (!label || !emoji || !answer) continue;
|
||||||
|
|
||||||
|
buttonRow.components.push({
|
||||||
|
type: 2, // Button
|
||||||
|
custom_id: `trivia_answer_${sessionId}_${i}`,
|
||||||
|
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||||
|
style: 2, // Secondary
|
||||||
|
emoji: { name: emoji }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
components.push(buttonRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give Up button in separate row
|
||||||
|
components.push({
|
||||||
|
type: 1, // Action Row
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 2, // Button
|
||||||
|
custom_id: `trivia_giveup_${sessionId}`,
|
||||||
|
label: 'Give Up',
|
||||||
|
style: 4, // Danger
|
||||||
|
emoji: { name: '🏳️' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
components,
|
||||||
|
flags: MessageFlags.IsComponentsV2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Components v2 result message
|
||||||
|
*/
|
||||||
|
export function getTriviaResultView(
|
||||||
|
result: TriviaResult,
|
||||||
|
question: string,
|
||||||
|
userAnswer?: string,
|
||||||
|
allAnswers?: string[],
|
||||||
|
entryFee: bigint = 0n
|
||||||
|
): {
|
||||||
|
components: any[];
|
||||||
|
flags: number;
|
||||||
|
} {
|
||||||
|
const { correct, reward, correctAnswer } = result;
|
||||||
|
const components: any[] = [];
|
||||||
|
|
||||||
|
if (correct) {
|
||||||
|
// Success container
|
||||||
|
components.push({
|
||||||
|
type: 17, // Container
|
||||||
|
accent_color: 0x57F287, // Green
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10, // Text Display
|
||||||
|
content: `# 🎉 Correct Answer!\n### ${question}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 14, // Separator
|
||||||
|
spacing: 1,
|
||||||
|
divider: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 10, // Text Display
|
||||||
|
content: `✅ **Your answer:** ${correctAnswer}\n\n💰 **Reward:** +${reward} AU\n\n🏆 Great job! Keep it up!`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const answerDisplay = userAnswer
|
||||||
|
? `❌ **Your answer:** ${userAnswer}\n✅ **Correct answer:** ${correctAnswer}`
|
||||||
|
: `✅ **Correct answer:** ${correctAnswer}`;
|
||||||
|
|
||||||
|
// Error container
|
||||||
|
components.push({
|
||||||
|
type: 17, // Container
|
||||||
|
accent_color: 0xED4245, // Red
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10, // Text Display
|
||||||
|
content: `# ❌ Incorrect Answer\n### ${question}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 14, // Separator
|
||||||
|
spacing: 1,
|
||||||
|
divider: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 10, // Text Display
|
||||||
|
content: `${answerDisplay}\n\n💸 **Entry fee lost:** ${entryFee} AU\n\n📚 Better luck next time!`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show disabled buttons with visual feedback
|
||||||
|
if (allAnswers && allAnswers.length > 0) {
|
||||||
|
const buttonRow: any = {
|
||||||
|
type: 1, // Action Row
|
||||||
|
components: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = ['A', 'B', 'C', 'D'];
|
||||||
|
const emojis = ['🇦', '🇧', '🇨', '🇩'];
|
||||||
|
|
||||||
|
for (let i = 0; i < allAnswers.length && i < 4; i++) {
|
||||||
|
const label = labels[i];
|
||||||
|
const emoji = emojis[i];
|
||||||
|
const answer = allAnswers[i];
|
||||||
|
|
||||||
|
if (!label || !emoji || !answer) continue;
|
||||||
|
|
||||||
|
const isCorrect = answer === correctAnswer;
|
||||||
|
const wasUserAnswer = answer === userAnswer;
|
||||||
|
|
||||||
|
buttonRow.components.push({
|
||||||
|
type: 2, // Button
|
||||||
|
custom_id: `trivia_result_${i}`,
|
||||||
|
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||||
|
style: isCorrect ? 3 : wasUserAnswer ? 4 : 2, // Success : Danger : Secondary
|
||||||
|
emoji: { name: isCorrect ? '✅' : wasUserAnswer ? '❌' : emoji },
|
||||||
|
disabled: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
components.push(buttonRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
components,
|
||||||
|
flags: MessageFlags.IsComponentsV2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Components v2 timeout message
|
||||||
|
*/
|
||||||
|
export function getTriviaTimeoutView(
|
||||||
|
question: string,
|
||||||
|
correctAnswer: string,
|
||||||
|
allAnswers?: string[],
|
||||||
|
entryFee: bigint = 0n
|
||||||
|
): {
|
||||||
|
components: any[];
|
||||||
|
flags: number;
|
||||||
|
} {
|
||||||
|
const components: any[] = [];
|
||||||
|
|
||||||
|
// Timeout container
|
||||||
|
components.push({
|
||||||
|
type: 17, // Container
|
||||||
|
accent_color: 0xFEE75C, // Yellow
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10, // Text Display
|
||||||
|
content: `# ⏱️ Time's Up!\n### ${question}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 14, // Separator
|
||||||
|
spacing: 1,
|
||||||
|
divider: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 10, // Text Display
|
||||||
|
content: `⏰ **You ran out of time!**\n✅ **Correct answer:** ${correctAnswer}\n\n💸 **Entry fee lost:** ${entryFee} AU\n\n⚡ Be faster next time!`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show disabled buttons with correct answer highlighted
|
||||||
|
if (allAnswers && allAnswers.length > 0) {
|
||||||
|
const buttonRow: any = {
|
||||||
|
type: 1, // Action Row
|
||||||
|
components: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = ['A', 'B', 'C', 'D'];
|
||||||
|
const emojis = ['🇦', '🇧', '🇨', '🇩'];
|
||||||
|
|
||||||
|
for (let i = 0; i < allAnswers.length && i < 4; i++) {
|
||||||
|
const label = labels[i];
|
||||||
|
const emoji = emojis[i];
|
||||||
|
const answer = allAnswers[i];
|
||||||
|
|
||||||
|
if (!label || !emoji || !answer) continue;
|
||||||
|
|
||||||
|
const isCorrect = answer === correctAnswer;
|
||||||
|
|
||||||
|
buttonRow.components.push({
|
||||||
|
type: 2, // Button
|
||||||
|
custom_id: `trivia_timeout_${i}`,
|
||||||
|
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||||
|
style: isCorrect ? 3 : 2, // Success : Secondary
|
||||||
|
emoji: { name: isCorrect ? '✅' : emoji },
|
||||||
|
disabled: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
components.push(buttonRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
components,
|
||||||
|
flags: MessageFlags.IsComponentsV2
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -70,6 +70,14 @@ export interface GameConfigType {
|
|||||||
autoTimeoutThreshold?: number;
|
autoTimeoutThreshold?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
trivia: {
|
||||||
|
entryFee: bigint;
|
||||||
|
rewardMultiplier: number;
|
||||||
|
timeoutSeconds: number;
|
||||||
|
cooldownMs: number;
|
||||||
|
categories: number[];
|
||||||
|
difficulty: 'easy' | 'medium' | 'hard' | 'random';
|
||||||
|
};
|
||||||
system: Record<string, any>;
|
system: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +171,21 @@ const configSchema = z.object({
|
|||||||
dmOnWarn: true
|
dmOnWarn: true
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
trivia: z.object({
|
||||||
|
entryFee: bigIntSchema,
|
||||||
|
rewardMultiplier: z.number().min(0).max(10),
|
||||||
|
timeoutSeconds: z.number().min(5).max(300),
|
||||||
|
cooldownMs: z.number().min(0),
|
||||||
|
categories: z.array(z.number()).default([]),
|
||||||
|
difficulty: z.enum(['easy', 'medium', 'hard', 'random']).default('random'),
|
||||||
|
}).default({
|
||||||
|
entryFee: 50n,
|
||||||
|
rewardMultiplier: 1.8,
|
||||||
|
timeoutSeconds: 30,
|
||||||
|
cooldownMs: 60000,
|
||||||
|
categories: [],
|
||||||
|
difficulty: 'random'
|
||||||
|
}),
|
||||||
system: z.record(z.string(), z.any()).default({}),
|
system: z.record(z.string(), z.any()).default({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export enum TimerType {
|
|||||||
EFFECT = 'EFFECT',
|
EFFECT = 'EFFECT',
|
||||||
ACCESS = 'ACCESS',
|
ACCESS = 'ACCESS',
|
||||||
EXAM_SYSTEM = 'EXAM_SYSTEM',
|
EXAM_SYSTEM = 'EXAM_SYSTEM',
|
||||||
|
TRIVIA_COOLDOWN = 'TRIVIA_COOLDOWN',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum EffectType {
|
export enum EffectType {
|
||||||
@@ -30,6 +31,8 @@ export enum TransactionType {
|
|||||||
TRADE_IN = 'TRADE_IN',
|
TRADE_IN = 'TRADE_IN',
|
||||||
TRADE_OUT = 'TRADE_OUT',
|
TRADE_OUT = 'TRADE_OUT',
|
||||||
QUEST_REWARD = 'QUEST_REWARD',
|
QUEST_REWARD = 'QUEST_REWARD',
|
||||||
|
TRIVIA_ENTRY = 'TRIVIA_ENTRY',
|
||||||
|
TRIVIA_WIN = 'TRIVIA_WIN',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ItemTransactionType {
|
export enum ItemTransactionType {
|
||||||
@@ -63,3 +66,22 @@ export enum LootType {
|
|||||||
XP = 'XP',
|
XP = 'XP',
|
||||||
ITEM = 'ITEM',
|
ITEM = 'ITEM',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum TriviaCategory {
|
||||||
|
GENERAL_KNOWLEDGE = 9,
|
||||||
|
BOOKS = 10,
|
||||||
|
FILM = 11,
|
||||||
|
MUSIC = 12,
|
||||||
|
VIDEO_GAMES = 15,
|
||||||
|
SCIENCE_NATURE = 17,
|
||||||
|
COMPUTERS = 18,
|
||||||
|
MATHEMATICS = 19,
|
||||||
|
MYTHOLOGY = 20,
|
||||||
|
SPORTS = 21,
|
||||||
|
GEOGRAPHY = 22,
|
||||||
|
HISTORY = 23,
|
||||||
|
POLITICS = 24,
|
||||||
|
ART = 25,
|
||||||
|
ANIMALS = 27,
|
||||||
|
ANIME_MANGA = 31,
|
||||||
|
}
|
||||||
|
|||||||
241
shared/modules/trivia/trivia.service.test.ts
Normal file
241
shared/modules/trivia/trivia.service.test.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
|
||||||
|
import { triviaService } from "./trivia.service";
|
||||||
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
|
import { users, userTimers } from "@db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { config } from "@shared/lib/config";
|
||||||
|
import { TimerType } from "@shared/lib/constants";
|
||||||
|
|
||||||
|
// Mock fetch for OpenTDB API
|
||||||
|
const mockFetch = mock(() => Promise.resolve({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
response_code: 0,
|
||||||
|
results: [{
|
||||||
|
category: Buffer.from('General Knowledge').toString('base64'),
|
||||||
|
type: 'multiple',
|
||||||
|
difficulty: Buffer.from('medium').toString('base64'),
|
||||||
|
question: Buffer.from('What is 2 + 2?').toString('base64'),
|
||||||
|
correct_answer: Buffer.from('4').toString('base64'),
|
||||||
|
incorrect_answers: [
|
||||||
|
Buffer.from('3').toString('base64'),
|
||||||
|
Buffer.from('5').toString('base64'),
|
||||||
|
Buffer.from('22').toString('base64'),
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
global.fetch = mockFetch as any;
|
||||||
|
|
||||||
|
describe("TriviaService", () => {
|
||||||
|
const TEST_USER_ID = "999999999";
|
||||||
|
const TEST_USERNAME = "testuser";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clean up test data
|
||||||
|
await DrizzleClient.delete(userTimers)
|
||||||
|
.where(eq(userTimers.userId, BigInt(TEST_USER_ID)));
|
||||||
|
|
||||||
|
// Ensure test user exists with sufficient balance
|
||||||
|
await DrizzleClient.insert(users)
|
||||||
|
.values({
|
||||||
|
id: BigInt(TEST_USER_ID),
|
||||||
|
username: TEST_USERNAME,
|
||||||
|
balance: 1000n,
|
||||||
|
xp: 0n,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [users.id],
|
||||||
|
set: {
|
||||||
|
balance: 1000n,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up
|
||||||
|
await DrizzleClient.delete(userTimers)
|
||||||
|
.where(eq(userTimers.userId, BigInt(TEST_USER_ID)));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchQuestion", () => {
|
||||||
|
it("should fetch and decode a trivia question", async () => {
|
||||||
|
const question = await triviaService.fetchQuestion();
|
||||||
|
|
||||||
|
expect(question).toBeDefined();
|
||||||
|
expect(question.question).toBe('What is 2 + 2?');
|
||||||
|
expect(question.correctAnswer).toBe('4');
|
||||||
|
expect(question.incorrectAnswers).toHaveLength(3);
|
||||||
|
expect(question.type).toBe('multiple');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canPlayTrivia", () => {
|
||||||
|
it("should allow playing when no cooldown exists", async () => {
|
||||||
|
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
|
||||||
|
|
||||||
|
expect(result.canPlay).toBe(true);
|
||||||
|
expect(result.nextAvailable).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prevent playing when on cooldown", async () => {
|
||||||
|
const futureDate = new Date(Date.now() + 60000);
|
||||||
|
|
||||||
|
await DrizzleClient.insert(userTimers).values({
|
||||||
|
userId: BigInt(TEST_USER_ID),
|
||||||
|
type: TimerType.TRIVIA_COOLDOWN,
|
||||||
|
key: 'default',
|
||||||
|
expiresAt: futureDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
|
||||||
|
|
||||||
|
expect(result.canPlay).toBe(false);
|
||||||
|
expect(result.nextAvailable).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow playing when cooldown has expired", async () => {
|
||||||
|
const pastDate = new Date(Date.now() - 1000);
|
||||||
|
|
||||||
|
await DrizzleClient.insert(userTimers).values({
|
||||||
|
userId: BigInt(TEST_USER_ID),
|
||||||
|
type: TimerType.TRIVIA_COOLDOWN,
|
||||||
|
key: 'default',
|
||||||
|
expiresAt: pastDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
|
||||||
|
|
||||||
|
expect(result.canPlay).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startTrivia", () => {
|
||||||
|
it("should start a trivia session and deduct entry fee", async () => {
|
||||||
|
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
|
||||||
|
|
||||||
|
expect(session).toBeDefined();
|
||||||
|
expect(session.sessionId).toContain(TEST_USER_ID);
|
||||||
|
expect(session.userId).toBe(TEST_USER_ID);
|
||||||
|
expect(session.question).toBeDefined();
|
||||||
|
expect(session.allAnswers).toHaveLength(4);
|
||||||
|
expect(session.entryFee).toBe(config.trivia.entryFee);
|
||||||
|
expect(session.potentialReward).toBeGreaterThan(0n);
|
||||||
|
|
||||||
|
// Verify balance deduction
|
||||||
|
const user = await DrizzleClient.query.users.findFirst({
|
||||||
|
where: eq(users.id, BigInt(TEST_USER_ID))
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(user?.balance).toBe(1000n - config.trivia.entryFee);
|
||||||
|
|
||||||
|
// Verify cooldown was set
|
||||||
|
const cooldown = await DrizzleClient.query.userTimers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(userTimers.userId, BigInt(TEST_USER_ID)),
|
||||||
|
eq(userTimers.type, TimerType.TRIVIA_COOLDOWN),
|
||||||
|
eq(userTimers.key, 'default')
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cooldown).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if user has insufficient balance", async () => {
|
||||||
|
// Set balance to less than entry fee
|
||||||
|
await DrizzleClient.update(users)
|
||||||
|
.set({ balance: 10n })
|
||||||
|
.where(eq(users.id, BigInt(TEST_USER_ID)));
|
||||||
|
|
||||||
|
await expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME))
|
||||||
|
.rejects.toThrow('Insufficient funds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if user is on cooldown", async () => {
|
||||||
|
const futureDate = new Date(Date.now() + 60000);
|
||||||
|
|
||||||
|
await DrizzleClient.insert(userTimers).values({
|
||||||
|
userId: BigInt(TEST_USER_ID),
|
||||||
|
type: TimerType.TRIVIA_COOLDOWN,
|
||||||
|
key: 'default',
|
||||||
|
expiresAt: futureDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME))
|
||||||
|
.rejects.toThrow('cooldown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("submitAnswer", () => {
|
||||||
|
it("should award prize for correct answer", async () => {
|
||||||
|
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
|
||||||
|
const balanceBefore = (await DrizzleClient.query.users.findFirst({
|
||||||
|
where: eq(users.id, BigInt(TEST_USER_ID))
|
||||||
|
}))!.balance!;
|
||||||
|
|
||||||
|
const result = await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true);
|
||||||
|
|
||||||
|
expect(result.correct).toBe(true);
|
||||||
|
expect(result.reward).toBe(session.potentialReward);
|
||||||
|
expect(result.correctAnswer).toBe(session.question.correctAnswer);
|
||||||
|
|
||||||
|
// Verify balance increase
|
||||||
|
const user = await DrizzleClient.query.users.findFirst({
|
||||||
|
where: eq(users.id, BigInt(TEST_USER_ID))
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(user?.balance).toBe(balanceBefore + session.potentialReward);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not award prize for incorrect answer", async () => {
|
||||||
|
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
|
||||||
|
const balanceBefore = (await DrizzleClient.query.users.findFirst({
|
||||||
|
where: eq(users.id, BigInt(TEST_USER_ID))
|
||||||
|
}))!.balance!;
|
||||||
|
|
||||||
|
const result = await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, false);
|
||||||
|
|
||||||
|
expect(result.correct).toBe(false);
|
||||||
|
expect(result.reward).toBe(0n);
|
||||||
|
expect(result.correctAnswer).toBe(session.question.correctAnswer);
|
||||||
|
|
||||||
|
// Verify balance unchanged (already deducted at start)
|
||||||
|
const user = await DrizzleClient.query.users.findFirst({
|
||||||
|
where: eq(users.id, BigInt(TEST_USER_ID))
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(user?.balance).toBe(balanceBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if session doesn't exist", async () => {
|
||||||
|
await expect(triviaService.submitAnswer("invalid_session", TEST_USER_ID, true))
|
||||||
|
.rejects.toThrow('Session not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prevent double submission", async () => {
|
||||||
|
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
|
||||||
|
|
||||||
|
await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true);
|
||||||
|
|
||||||
|
// Try to submit again
|
||||||
|
await expect(triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true))
|
||||||
|
.rejects.toThrow('Session not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSession", () => {
|
||||||
|
it("should retrieve active session", async () => {
|
||||||
|
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
|
||||||
|
const retrieved = triviaService.getSession(session.sessionId);
|
||||||
|
|
||||||
|
expect(retrieved).toBeDefined();
|
||||||
|
expect(retrieved?.sessionId).toBe(session.sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined for non-existent session", () => {
|
||||||
|
const retrieved = triviaService.getSession("invalid_session");
|
||||||
|
|
||||||
|
expect(retrieved).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
313
shared/modules/trivia/trivia.service.ts
Normal file
313
shared/modules/trivia/trivia.service.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { users, userTimers, transactions } from "@db/schema";
|
||||||
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
|
import { config } from "@shared/lib/config";
|
||||||
|
import { withTransaction } from "@/lib/db";
|
||||||
|
import type { Transaction } from "@shared/lib/types";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import { TimerType, TransactionType } from "@shared/lib/constants";
|
||||||
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
|
|
||||||
|
// OpenTDB API Response Types
|
||||||
|
interface OpenTDBResponse {
|
||||||
|
response_code: number;
|
||||||
|
results: Array<{
|
||||||
|
category: string;
|
||||||
|
type: 'boolean' | 'multiple';
|
||||||
|
difficulty: string;
|
||||||
|
question: string;
|
||||||
|
correct_answer: string;
|
||||||
|
incorrect_answers: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriviaQuestion {
|
||||||
|
question: string;
|
||||||
|
correctAnswer: string;
|
||||||
|
incorrectAnswers: string[];
|
||||||
|
type: 'boolean' | 'multiple';
|
||||||
|
difficulty: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriviaSession {
|
||||||
|
sessionId: string;
|
||||||
|
userId: string;
|
||||||
|
question: TriviaQuestion;
|
||||||
|
allAnswers: string[];
|
||||||
|
correctIndex: number;
|
||||||
|
expiresAt: Date;
|
||||||
|
entryFee: bigint;
|
||||||
|
potentialReward: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriviaResult {
|
||||||
|
correct: boolean;
|
||||||
|
reward: bigint;
|
||||||
|
correctAnswer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TriviaService {
|
||||||
|
private activeSessions: Map<string, TriviaSession> = new Map();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Cleanup expired sessions every 30 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
this.cleanupExpiredSessions();
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupExpiredSessions() {
|
||||||
|
const now = Date.now();
|
||||||
|
const expired: string[] = [];
|
||||||
|
|
||||||
|
for (const [sessionId, session] of this.activeSessions.entries()) {
|
||||||
|
if (session.expiresAt.getTime() < now) {
|
||||||
|
expired.push(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sessionId of expired) {
|
||||||
|
this.activeSessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expired.length > 0) {
|
||||||
|
console.log(`[TriviaService] Cleaned up ${expired.length} expired sessions.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a trivia question from OpenTDB API
|
||||||
|
*/
|
||||||
|
async fetchQuestion(category?: number, difficulty?: string): Promise<TriviaQuestion> {
|
||||||
|
let url = 'https://opentdb.com/api.php?amount=1&encode=base64';
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
url += `&category=${category}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (difficulty && difficulty !== 'random') {
|
||||||
|
url += `&difficulty=${difficulty}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json() as OpenTDBResponse;
|
||||||
|
|
||||||
|
if (data.response_code !== 0 || !data.results || data.results.length === 0) {
|
||||||
|
throw new Error('Failed to fetch trivia question');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = data.results[0];
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('No trivia question returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64
|
||||||
|
return {
|
||||||
|
category: Buffer.from(result.category, 'base64').toString('utf-8'),
|
||||||
|
type: result.type,
|
||||||
|
difficulty: Buffer.from(result.difficulty, 'base64').toString('utf-8'),
|
||||||
|
question: Buffer.from(result.question, 'base64').toString('utf-8'),
|
||||||
|
correctAnswer: Buffer.from(result.correct_answer, 'base64').toString('utf-8'),
|
||||||
|
incorrectAnswers: result.incorrect_answers.map(ans =>
|
||||||
|
Buffer.from(ans, 'base64').toString('utf-8')
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TriviaService] Error fetching question:', error);
|
||||||
|
throw new UserError('Failed to fetch trivia question. Please try again later.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can play trivia (cooldown check)
|
||||||
|
*/
|
||||||
|
async canPlayTrivia(userId: string): Promise<{ canPlay: boolean; nextAvailable?: Date }> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const cooldown = await DrizzleClient.query.userTimers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(userTimers.userId, BigInt(userId)),
|
||||||
|
eq(userTimers.type, TimerType.TRIVIA_COOLDOWN),
|
||||||
|
eq(userTimers.key, 'default')
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cooldown && cooldown.expiresAt > now) {
|
||||||
|
return { canPlay: false, nextAvailable: cooldown.expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canPlay: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a trivia session - deducts entry fee and creates session
|
||||||
|
*/
|
||||||
|
async startTrivia(userId: string, username: string, categoryId?: number): Promise<TriviaSession> {
|
||||||
|
// Check cooldown
|
||||||
|
const cooldownCheck = await this.canPlayTrivia(userId);
|
||||||
|
if (!cooldownCheck.canPlay) {
|
||||||
|
const timestamp = Math.floor(cooldownCheck.nextAvailable!.getTime() / 1000);
|
||||||
|
throw new UserError(`You're on cooldown! Try again <t:${timestamp}:R>.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryFee = config.trivia.entryFee;
|
||||||
|
|
||||||
|
return await withTransaction(async (tx) => {
|
||||||
|
// Check balance
|
||||||
|
const user = await tx.query.users.findFirst({
|
||||||
|
where: eq(users.id, BigInt(userId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UserError('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((user.balance ?? 0n) < entryFee) {
|
||||||
|
throw new UserError(`Insufficient funds! You need ${entryFee} AU to play trivia.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct entry fee (SINK MECHANISM)
|
||||||
|
await tx.update(users)
|
||||||
|
.set({
|
||||||
|
balance: sql`${users.balance} - ${entryFee}`,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, BigInt(userId)));
|
||||||
|
|
||||||
|
// Record transaction
|
||||||
|
await tx.insert(transactions).values({
|
||||||
|
userId: BigInt(userId),
|
||||||
|
amount: -entryFee,
|
||||||
|
type: TransactionType.TRIVIA_ENTRY,
|
||||||
|
description: 'Trivia entry fee',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch question
|
||||||
|
let category = categoryId;
|
||||||
|
if (!category) {
|
||||||
|
category = config.trivia.categories.length > 0
|
||||||
|
? config.trivia.categories[Math.floor(Math.random() * config.trivia.categories.length)]
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficulty = config.trivia.difficulty;
|
||||||
|
const question = await this.fetchQuestion(category, difficulty);
|
||||||
|
|
||||||
|
// Shuffle answers
|
||||||
|
const allAnswers = [...question.incorrectAnswers, question.correctAnswer];
|
||||||
|
const shuffled = allAnswers.sort(() => Math.random() - 0.5);
|
||||||
|
const correctIndex = shuffled.indexOf(question.correctAnswer);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionId = `${userId}_${Date.now()}`;
|
||||||
|
const expiresAt = new Date(Date.now() + config.trivia.timeoutSeconds * 1000);
|
||||||
|
const potentialReward = BigInt(Math.floor(Number(entryFee) * config.trivia.rewardMultiplier));
|
||||||
|
|
||||||
|
const session: TriviaSession = {
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
question,
|
||||||
|
allAnswers: shuffled,
|
||||||
|
correctIndex,
|
||||||
|
expiresAt,
|
||||||
|
entryFee,
|
||||||
|
potentialReward,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.activeSessions.set(sessionId, session);
|
||||||
|
|
||||||
|
// Set cooldown
|
||||||
|
const cooldownEnd = new Date(Date.now() + config.trivia.cooldownMs);
|
||||||
|
await tx.insert(userTimers)
|
||||||
|
.values({
|
||||||
|
userId: BigInt(userId),
|
||||||
|
type: TimerType.TRIVIA_COOLDOWN,
|
||||||
|
key: 'default',
|
||||||
|
expiresAt: cooldownEnd,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [userTimers.userId, userTimers.type, userTimers.key],
|
||||||
|
set: { expiresAt: cooldownEnd },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Record dashboard event
|
||||||
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||||
|
await dashboardService.recordEvent({
|
||||||
|
type: 'info',
|
||||||
|
message: `${username} started a trivia game (${question.difficulty})`,
|
||||||
|
icon: '🎯'
|
||||||
|
});
|
||||||
|
|
||||||
|
return session;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session by ID
|
||||||
|
*/
|
||||||
|
getSession(sessionId: string): TriviaSession | undefined {
|
||||||
|
return this.activeSessions.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit answer and process reward
|
||||||
|
*/
|
||||||
|
async submitAnswer(sessionId: string, userId: string, isCorrect: boolean): Promise<TriviaResult> {
|
||||||
|
const session = this.activeSessions.get(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new UserError('Session not found or expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.userId !== userId) {
|
||||||
|
throw new UserError('This is not your trivia question!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove session to prevent double-submit
|
||||||
|
this.activeSessions.delete(sessionId);
|
||||||
|
|
||||||
|
const reward = isCorrect ? session.potentialReward : 0n;
|
||||||
|
|
||||||
|
if (isCorrect) {
|
||||||
|
await withTransaction(async (tx) => {
|
||||||
|
// Award prize
|
||||||
|
await tx.update(users)
|
||||||
|
.set({
|
||||||
|
balance: (await tx.query.users.findFirst({
|
||||||
|
where: eq(users.id, BigInt(userId))
|
||||||
|
}))!.balance! + reward,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, BigInt(userId)));
|
||||||
|
|
||||||
|
// Record transaction
|
||||||
|
await tx.insert(transactions).values({
|
||||||
|
userId: BigInt(userId),
|
||||||
|
amount: reward,
|
||||||
|
type: TransactionType.TRIVIA_WIN,
|
||||||
|
description: 'Trivia prize',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Record dashboard event
|
||||||
|
const user = await tx.query.users.findFirst({
|
||||||
|
where: eq(users.id, BigInt(userId))
|
||||||
|
});
|
||||||
|
|
||||||
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||||
|
await dashboardService.recordEvent({
|
||||||
|
type: 'success',
|
||||||
|
message: `${user?.username} won ${reward.toLocaleString()} AU from trivia!`,
|
||||||
|
icon: '🎉'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
correct: isCorrect,
|
||||||
|
reward,
|
||||||
|
correctAnswer: session.question.correctAnswer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const triviaService = new TriviaService();
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# DASH-003: Visual Analytics & Activity Charts
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
**Created:** 2026-01-08
|
|
||||||
**Tags:** dashboard, analytics, charts, frontend
|
|
||||||
|
|
||||||
## 1. Context & User Story
|
|
||||||
* **As a:** Bot Administrator
|
|
||||||
* **I want to:** View a graphical representation of bot usage over the last 24 hours.
|
|
||||||
* **So that:** I can identify peak usage times and trends in command execution.
|
|
||||||
|
|
||||||
## 2. Technical Requirements
|
|
||||||
### Data Model Changes
|
|
||||||
- [x] No new tables.
|
|
||||||
- [x] Requires complex aggregation queries on the `transactions` table.
|
|
||||||
|
|
||||||
### API / Interface
|
|
||||||
- [x] `GET /api/stats/activity`: Returns an array of data points for the last 24 hours (hourly granularity).
|
|
||||||
- [x] Response Structure: `Array<{ hour: string, commands: number, transactions: number }>`.
|
|
||||||
|
|
||||||
## 3. Constraints & Validations (CRITICAL)
|
|
||||||
- **Input Validation:** Hourly buckets must be strictly validated for the 24h window.
|
|
||||||
- **System Constraints:**
|
|
||||||
- Database query must be cached for at least 5 minutes as it involves heavy aggregation.
|
|
||||||
- Chart must be responsive and handle mobile viewports.
|
|
||||||
- **Business Logic Guardrails:**
|
|
||||||
- If no data exists for an hour, it must return 0 rather than skipping the point.
|
|
||||||
|
|
||||||
## 4. Acceptance Criteria
|
|
||||||
1. [x] **Given** a 24-hour history of transactions, **When** the dashboard loads, **Then** a line or area chart displays the command volume over time.
|
|
||||||
2. [x] **Given** the premium glassmorphic theme, **When** the chart is rendered, **Then** it must use the primary brand colors and gradients to match the UI.
|
|
||||||
3. [x] **Given** a mouse hover on the chart, **When** hovering over a point, **Then** a glassmorphic tooltip shows exact counts for that hour.
|
|
||||||
|
|
||||||
## 5. Implementation Plan
|
|
||||||
- [x] Step 1: Add an aggregation method to `dashboard.service.ts` to fetch hourly counts from the `transactions` table.
|
|
||||||
- [x] Step 2: Create the `/api/stats/activity` endpoint.
|
|
||||||
- [x] Step 3: Install a charting library (`recharts`).
|
|
||||||
- [x] Step 4: Implement the `ActivityChart` component into the middle column of the dashboard.
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
Implemented a comprehensive activity analytics system for the Aurora dashboard:
|
|
||||||
|
|
||||||
### Backend Changes
|
|
||||||
- **Service Layer**: Added `getActivityAggregation` to `dashboard.service.ts`. It performs a hourly aggregation on the `transactions` table using Postgres `date_trunc` and `FILTER` clauses to differentiate between "commands" and "total transactions". Missing hours in the 24h window are automatically filled with zero-values.
|
|
||||||
- **API**: Implemented `GET /api/stats/activity` in `web/src/server.ts` with a 5-minute in-memory cache to maintain server performance.
|
|
||||||
|
|
||||||
### Frontend Changes
|
|
||||||
- **Library**: Added `recharts` for high-performance SVG charting.
|
|
||||||
- **Hooks**: Created `use-activity-stats.ts` to manage the lifecycle and polling of analytics data.
|
|
||||||
- **Components**: Developed `ActivityChart.tsx` featuring:
|
|
||||||
- Premium glassmorphic styling (backdrop blur, subtle borders).
|
|
||||||
- Responsive `AreaChart` with brand-matching gradients.
|
|
||||||
- Custom glassmorphic tooltip with precise data point values.
|
|
||||||
- Smooth entry animations.
|
|
||||||
- **Integration**: Placed the new analytics card prominently in the `Dashboard.tsx` layout.
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
- **Unit Tests**: Added comprehensive test cases to `dashboard.service.test.ts` verifying the 24-point guaranteed response and correct data mapping.
|
|
||||||
- **Type Safety**: Passed `bun x tsc --noEmit` with zero errors.
|
|
||||||
- **Runtime**: All tests passing.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# DASH-004: Administrative Control Panel
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
**Created:** 2026-01-08
|
|
||||||
**Tags:** dashboard, control-panel, bot-actions, operations
|
|
||||||
|
|
||||||
## 1. Context & User Story
|
|
||||||
* **As a:** Bot Administrator
|
|
||||||
* **I want to:** Execute common maintenance tasks directly from the dashboard buttons.
|
|
||||||
* **So that:** I don't have to use terminal commands or Discord slash commands for system-level operations.
|
|
||||||
|
|
||||||
## 2. Technical Requirements
|
|
||||||
### Data Model Changes
|
|
||||||
- [ ] N/A.
|
|
||||||
|
|
||||||
### API / Interface
|
|
||||||
- [ ] `POST /api/actions/reload-commands`: Triggers the bot's command loader.
|
|
||||||
- [ ] `POST /api/actions/clear-cache`: Clears internal bot caches.
|
|
||||||
- [ ] `POST /api/actions/maintenance-mode`: Toggles a maintenance flag for the bot.
|
|
||||||
|
|
||||||
## 3. Constraints & Validations (CRITICAL)
|
|
||||||
- **Input Validation:** Standard JSON body with optional `reason` field.
|
|
||||||
- **System Constraints:**
|
|
||||||
- Actions must be idempotent where possible.
|
|
||||||
- Actions must provide a response within 10 seconds.
|
|
||||||
- **Business Logic Guardrails:**
|
|
||||||
- **SECURITY**: This endpoint MUST require high-privilege authentication (currently we have single admin assumption, but token-based check should be planned).
|
|
||||||
- Maintenance mode toggle must be logged to the event feed.
|
|
||||||
|
|
||||||
## 4. Acceptance Criteria
|
|
||||||
1. [ ] **Given** a "Quick Actions" card, **When** the "Reload Commands" button is clicked, **Then** the bot reloads its local command files and posts a "Success" event to the feed.
|
|
||||||
2. [ ] **Given** a running bot, **When** the "Clear Cache" button is pushed, **Then** the bot flushes its internal memory maps and the memory usage metric reflects the drop.
|
|
||||||
|
|
||||||
## 5. Implementation Plan
|
|
||||||
- [x] Step 1: Create an `action.service.ts` to handle the logic of triggering bot-specific functions.
|
|
||||||
- [x] Step 2: Implement the `/api/actions` route group.
|
|
||||||
- [x] Step 3: Design a "Quick Actions" card with premium styled buttons in `Dashboard.tsx`.
|
|
||||||
- [x] Step 4: Add loading states to buttons to show when an operation is "In Progress."
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
Successfully implemented the Administrative Control Panel with the following changes:
|
|
||||||
- **Backend Service**: Created `shared/modules/admin/action.service.ts` to coordinate actions like reloading commands, clearing cache, and toggling maintenance mode.
|
|
||||||
- **System Bus**: Updated `shared/lib/events.ts` with new action events.
|
|
||||||
- **API Endpoints**: Added `POST /api/actions/*` routes to the web server in `web/src/server.ts`.
|
|
||||||
- **Bot Integration**:
|
|
||||||
- Updated `AuroraClient` in `bot/lib/BotClient.ts` to listen for system action events.
|
|
||||||
- Implemented `maintenanceMode` flag in `AuroraClient`.
|
|
||||||
- Updated `CommandHandler.ts` to respect maintenance mode, blocking user commands with a helpful error embed.
|
|
||||||
- **Frontend UI**:
|
|
||||||
- Created `ControlPanel.tsx` component with a premium glassmorphic design and real-time state feedback.
|
|
||||||
- Integrated `ControlPanel` into the `Dashboard.tsx` page.
|
|
||||||
- Updated `use-dashboard-stats` hook and shared types to include maintenance mode status.
|
|
||||||
- **Verification**: Created 3 new test suites covering the service, the bot listener, and the command handler enforcement. All tests passing.
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
# DASH-001: Dashboard Real Data Integration
|
|
||||||
|
|
||||||
**Status:** In Review
|
|
||||||
**Created:** 2026-01-08
|
|
||||||
**Tags:** dashboard, api, discord-client, database, real-time
|
|
||||||
|
|
||||||
## 1. Context & User Story
|
|
||||||
* **As a:** Bot Administrator
|
|
||||||
* **I want to:** See real data on the dashboard instead of mock/hardcoded values
|
|
||||||
* **So that:** I can monitor actual bot metrics, user activity, and system health in real-time
|
|
||||||
|
|
||||||
## 2. Technical Requirements
|
|
||||||
|
|
||||||
### Data Model Changes
|
|
||||||
- [ ] No new tables required
|
|
||||||
- [ ] SQL migration required? **No** – existing schema already has `users`, `transactions`, `moderationCases`, and other relevant tables
|
|
||||||
|
|
||||||
### API / Interface
|
|
||||||
|
|
||||||
#### New Dashboard Stats Service
|
|
||||||
Create a new service at `shared/modules/dashboard/dashboard.service.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface DashboardStats {
|
|
||||||
guilds: {
|
|
||||||
count: number;
|
|
||||||
changeFromLastMonth?: number;
|
|
||||||
};
|
|
||||||
users: {
|
|
||||||
active: number;
|
|
||||||
changePercentFromLastMonth?: number;
|
|
||||||
};
|
|
||||||
commands: {
|
|
||||||
total: number;
|
|
||||||
changePercentFromLastMonth?: number;
|
|
||||||
};
|
|
||||||
ping: {
|
|
||||||
avg: number;
|
|
||||||
changeFromLastHour?: number;
|
|
||||||
};
|
|
||||||
recentEvents: RecentEvent[];
|
|
||||||
activityOverview: ActivityDataPoint[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecentEvent {
|
|
||||||
type: 'success' | 'error' | 'info';
|
|
||||||
message: string;
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### API Endpoints
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `GET` | `/api/stats` | Returns `DashboardStats` object |
|
|
||||||
| `GET` | `/api/stats/realtime` | WebSocket/SSE for live updates |
|
|
||||||
|
|
||||||
### Discord Client Data
|
|
||||||
|
|
||||||
The `AuroraClient` (exported from `bot/lib/BotClient.ts`) provides access to:
|
|
||||||
|
|
||||||
| Property | Data Source | Dashboard Metric |
|
|
||||||
|----------|-------------|------------------|
|
|
||||||
| `client.guilds.cache.size` | Discord.js | Total Servers |
|
|
||||||
| `client.users.cache.size` | Discord.js | Active Users (approximate) |
|
|
||||||
| `client.ws.ping` | Discord.js | Avg Ping |
|
|
||||||
| `client.commands.size` | Bot commands | Commands Registered |
|
|
||||||
| `client.lastCommandTimestamp` | Custom property | Last command run time |
|
|
||||||
|
|
||||||
### Database Data
|
|
||||||
|
|
||||||
Query from existing tables:
|
|
||||||
|
|
||||||
| Metric | Query |
|
|
||||||
|--------|-------|
|
|
||||||
| User count (registered) | `SELECT COUNT(*) FROM users WHERE is_active = true` |
|
|
||||||
| Commands executed (today) | `SELECT COUNT(*) FROM transactions WHERE type = 'COMMAND_RUN' AND created_at >= NOW() - INTERVAL '1 day'` |
|
|
||||||
| Recent moderation events | `SELECT * FROM moderation_cases ORDER BY created_at DESC LIMIT 10` |
|
|
||||||
| Recent transactions | `SELECT * FROM transactions ORDER BY created_at DESC LIMIT 10` |
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> The Discord client instance (`AuroraClient`) is in the `bot` package, while the web server is in the `web` package. Need to establish cross-package communication:
|
|
||||||
> - **Option A**: Export client reference from `bot` and import in `web` (same process, simple)
|
|
||||||
> - **Option B**: IPC via shared memory or message queue (separate processes)
|
|
||||||
> - **Option C**: Internal HTTP/WebSocket between bot and web (microservice pattern)
|
|
||||||
|
|
||||||
## 3. Constraints & Validations (CRITICAL)
|
|
||||||
|
|
||||||
- **Input Validation:**
|
|
||||||
- API endpoints must not accept arbitrary query parameters
|
|
||||||
- Rate limiting on `/api/stats` to prevent abuse (max 60 requests/minute per IP)
|
|
||||||
|
|
||||||
- **System Constraints:**
|
|
||||||
- Discord API rate limits apply when fetching guild/user data
|
|
||||||
- Cache Discord data and refresh at most every 30 seconds
|
|
||||||
- Database queries should be optimized with existing indices
|
|
||||||
- API response timeout: 5 seconds maximum
|
|
||||||
|
|
||||||
- **Business Logic Guardrails:**
|
|
||||||
- Do not expose sensitive user data (only aggregates)
|
|
||||||
- Do not expose Discord tokens or internal IDs in API responses
|
|
||||||
- Activity history limited to last 24 hours to prevent performance issues
|
|
||||||
- User counts should count only registered users, not all Discord users
|
|
||||||
|
|
||||||
## 4. Acceptance Criteria
|
|
||||||
|
|
||||||
1. [ ] **Given** the dashboard is loaded, **When** the API `/api/stats` is called, **Then** it returns real guild count from Discord client
|
|
||||||
2. [ ] **Given** the bot is connected to Discord, **When** viewing the dashboard, **Then** the "Total Servers" shows actual `guilds.cache.size`
|
|
||||||
3. [ ] **Given** users are registered in the database, **When** viewing the dashboard, **Then** "Active Users" shows count from `users` table where `is_active = true`
|
|
||||||
4. [ ] **Given** the bot is running, **When** viewing the dashboard, **Then** "Avg Ping" shows actual `client.ws.ping` value
|
|
||||||
5. [ ] **Given** recent bot activity occurred, **When** viewing "Recent Events", **Then** events from `transactions` and `moderation_cases` tables are displayed
|
|
||||||
6. [ ] **Given** mock data exists in components, **When** the feature is complete, **Then** all hardcoded values in `Dashboard.tsx` are replaced with API data
|
|
||||||
|
|
||||||
## 5. Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1: Data Layer & Services
|
|
||||||
- [ ] Create `shared/modules/dashboard/dashboard.service.ts` with statistics aggregation functions
|
|
||||||
- [ ] Add helper to query active user count from database
|
|
||||||
- [ ] Add helper to query recent transactions (as events)
|
|
||||||
- [ ] Add helper to query moderation cases (as events)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: Discord Client Exposure
|
|
||||||
- [ ] Create a client stats provider that exposes Discord metrics
|
|
||||||
- [ ] Implement caching layer to avoid rate limiting (30-second TTL)
|
|
||||||
- [ ] Export stats getter from `bot` package for `web` package consumption
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3: API Implementation
|
|
||||||
- [ ] Add `/api/stats` endpoint in `web/src/server.ts`
|
|
||||||
- [ ] Wire up `dashboard.service.ts` functions to API
|
|
||||||
- [ ] Add error handling and response formatting
|
|
||||||
- [ ] Consider adding rate limiting middleware
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4: Frontend Integration
|
|
||||||
- [ ] Create custom React hook `useDashboardStats()` for data fetching
|
|
||||||
- [ ] Replace hardcoded values in `Dashboard.tsx` with hook data
|
|
||||||
- [ ] Add loading states and error handling
|
|
||||||
- [ ] Implement auto-refresh (poll every 30 seconds or use SSE/WebSocket)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 5: Activity Overview Chart
|
|
||||||
- [ ] Query hourly command/transaction counts for last 24 hours
|
|
||||||
- [ ] Integrate charting library (e.g., Recharts, Chart.js)
|
|
||||||
- [ ] Replace "Chart Placeholder" with actual chart component
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Decision Required
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> **Key Decision: How should the web server access Discord client data?**
|
|
||||||
>
|
|
||||||
> The bot and web server currently run in the same process. Recommend:
|
|
||||||
> - **Short term**: Direct import of `AuroraClient` singleton in API handlers
|
|
||||||
> - **Long term**: Consider event bus or shared state manager if splitting to microservices
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- User authentication/authorization for API endpoints
|
|
||||||
- Historical data beyond 24 hours
|
|
||||||
- Command execution tracking (would require new database table)
|
|
||||||
- Guild-specific analytics (separate feature)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
**Status:** In Review
|
|
||||||
**Implemented:** 2026-01-08
|
|
||||||
**Branch:** `feat/dashboard-real-data-integration`
|
|
||||||
**Commit:** `17cb70e`
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
|
|
||||||
#### New Files Created (7)
|
|
||||||
1. `shared/modules/dashboard/dashboard.types.ts` - TypeScript interfaces
|
|
||||||
2. `shared/modules/dashboard/dashboard.service.ts` - Database query service
|
|
||||||
3. `shared/modules/dashboard/dashboard.service.test.ts` - Service unit tests
|
|
||||||
4. `bot/lib/clientStats.ts` - Discord client stats provider with caching
|
|
||||||
5. `bot/lib/clientStats.test.ts` - Client stats unit tests
|
|
||||||
6. `web/src/hooks/use-dashboard-stats.ts` - React hook for data fetching
|
|
||||||
7. `tickets/2026-01-08-dashboard-real-data-integration.md` - This ticket
|
|
||||||
|
|
||||||
#### Modified Files (3)
|
|
||||||
1. `web/src/server.ts` - Added `/api/stats` endpoint
|
|
||||||
2. `web/src/pages/Dashboard.tsx` - Integrated real data with loading/error states
|
|
||||||
3. `.gitignore` - Removed `tickets/` to track tickets in version control
|
|
||||||
|
|
||||||
### Test Results
|
|
||||||
```
|
|
||||||
✓ 11 tests passing
|
|
||||||
✓ TypeScript check clean (bun x tsc --noEmit)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Architecture Decision
|
|
||||||
Used **Option A** (direct import) for accessing `AuroraClient` from web server, as both run in the same process. This is the simplest approach and avoids unnecessary complexity.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# DASH-002: Real-time Live Updates via WebSockets
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
**Created:** 2026-01-08
|
|
||||||
**Tags:** dashboard, websocket, real-time, performance
|
|
||||||
|
|
||||||
## 1. Context & User Story
|
|
||||||
* **As a:** Bot Administrator
|
|
||||||
* **I want to:** See metrics and events update instantly on my screen without refreshing or waiting for polling intervals.
|
|
||||||
* **So that:** I can react immediately to errors or spikes in latency and have a dashboard that feels "alive."
|
|
||||||
|
|
||||||
## 2. Technical Requirements
|
|
||||||
### Data Model Changes
|
|
||||||
- [x] No database schema changes required.
|
|
||||||
- [x] Created `shared/lib/events.ts` for a global system event bus.
|
|
||||||
|
|
||||||
### API / Interface
|
|
||||||
- [x] Establish a WebSocket endpoint at `/ws`.
|
|
||||||
- [x] Define the message protocol:
|
|
||||||
- `STATS_UPDATE`: Server to client containing full `DashboardStats`.
|
|
||||||
- `NEW_EVENT`: Server to client when a specific event is recorded.
|
|
||||||
|
|
||||||
## 3. Constraints & Validations (CRITICAL)
|
|
||||||
- **Input Validation:** WS messages validated using JSON parsing and type checks.
|
|
||||||
- **System Constraints:**
|
|
||||||
- WebSocket broadcast interval set to 5s for metrics.
|
|
||||||
- Automatic reconnection logic handled in the frontend hook.
|
|
||||||
- **Business Logic Guardrails:**
|
|
||||||
- Events are pushed immediately as they occur via the system event bus.
|
|
||||||
|
|
||||||
## 4. Acceptance Criteria
|
|
||||||
1. [x] **Given** the dashboard is open, **When** a command is run in Discord (e.g. Daily), **Then** the "Recent Events" list updates instantly on the web UI.
|
|
||||||
2. [x] **Given** a changing network environment, **When** the bot's ping fluctuates, **Then** the "Avg Latency" card updates in real-time.
|
|
||||||
3. [x] **Given** a connection loss, **When** the network returns, **Then** the client automatically reconnects to the WS room.
|
|
||||||
|
|
||||||
## 5. Implementation Plan
|
|
||||||
- [x] Step 1: Integrate a WebSocket library into `web/src/server.ts` using Bun's native `websocket` support.
|
|
||||||
- [x] Step 2: Implement a broadcast system in `dashboard.service.ts` to push events to the WS handler using `systemEvents`.
|
|
||||||
- [x] Step 3: Create/Update `useDashboardStats` hook in the frontend to handle connection lifecycle and state merging.
|
|
||||||
- [x] Step 4: Refactor `Dashboard.tsx` state consumption to benefit from real-time updates.
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
### Files Changed
|
|
||||||
- `shared/lib/events.ts`: New event bus for the system.
|
|
||||||
- `web/src/server.ts`: Added WebSocket handler and stats broadcast.
|
|
||||||
- `web/src/hooks/use-dashboard-stats.ts`: Replaced polling with WebSocket + HTTP initial load.
|
|
||||||
- `shared/modules/dashboard/dashboard.service.ts`: Added `recordEvent` helper to emit WS events.
|
|
||||||
- `shared/modules/economy/economy.service.ts`: Integrated `recordEvent` into daily claims and transfers.
|
|
||||||
- `shared/modules/dashboard/dashboard.service.test.ts`: Added unit tests for event emission.
|
|
||||||
@@ -88,6 +88,14 @@ const formSchema = z.object({
|
|||||||
autoTimeoutThreshold: z.number().optional()
|
autoTimeoutThreshold: z.number().optional()
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
trivia: z.object({
|
||||||
|
entryFee: bigIntStringSchema,
|
||||||
|
rewardMultiplier: z.number(),
|
||||||
|
timeoutSeconds: z.number(),
|
||||||
|
cooldownMs: z.number(),
|
||||||
|
categories: z.array(z.number()).default([]),
|
||||||
|
difficulty: z.enum(['easy', 'medium', 'hard', 'random']),
|
||||||
|
}).optional(),
|
||||||
system: z.record(z.string(), z.any()).optional(),
|
system: z.record(z.string(), z.any()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -747,6 +755,98 @@ export function SettingsDrawer() {
|
|||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="trivia" className="border border-border/40 rounded-xl bg-card/30 px-4 transition-all data-[state=open]:border-primary/20 data-[state=open]:bg-card/50">
|
||||||
|
<AccordionTrigger className="hover:no-underline py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-purple-500/10 flex items-center justify-center text-purple-500">
|
||||||
|
🎯
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">Trivia</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pb-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="trivia.entryFee"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Entry Fee (AU)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="text" className="bg-background/50" placeholder="50" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription className="text-xs">Cost to play (currency sink)</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="trivia.rewardMultiplier"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Reward Multiplier</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="number" step="0.1" className="bg-background/50" onChange={e => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription className="text-xs">Prize = Entry × Multiplier</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="trivia.timeoutSeconds"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Answer Time Limit (seconds)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="number" className="bg-background/50" onChange={e => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="trivia.cooldownMs"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Cooldown (ms)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="number" className="bg-background/50" onChange={e => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="trivia.difficulty"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Difficulty</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="bg-background/50">
|
||||||
|
<SelectValue placeholder="Select difficulty" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="easy">Easy</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="hard">Hard</SelectItem>
|
||||||
|
<SelectItem value="random">Random</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription className="text-xs">Question difficulty level</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem value="moderation" className="border border-border/40 rounded-xl bg-card/30 px-4 transition-all data-[state=open]:border-primary/20 data-[state=open]:bg-card/50">
|
<AccordionItem value="moderation" className="border border-border/40 rounded-xl bg-card/30 px-4 transition-all data-[state=open]:border-primary/20 data-[state=open]:bg-card/50">
|
||||||
<AccordionTrigger className="hover:no-underline py-4">
|
<AccordionTrigger className="hover:no-underline py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user