k-l-lambda Claude commited on
Commit
15f353f
·
1 Parent(s): 0b679f2

Update trigo-web with VS People multiplayer mode

Browse files

Changes include:
- VS People mode: real-time multiplayer via Socket.io
- Room management with URL hash sharing
- Turn-based gameplay with validation
- Pass and stone placement synchronization
- Session storage fix for multiplayer mode
- Updated ONNX model paths
- Various bug fixes and improvements

Co-Authored-By: Claude <noreply@anthropic.com>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. trigo-web/.claude/agents/agentlog-updater.md +77 -0
  2. trigo-web/.env +76 -0
  3. trigo-web/.env.local.example +43 -0
  4. trigo-web/app/.env +8 -3
  5. trigo-web/app/package-lock.json +689 -118
  6. trigo-web/app/package.json +3 -0
  7. trigo-web/app/src/components/InlineNicknameEditor.vue +225 -0
  8. trigo-web/app/src/components/mcts/MCTSBoardHeatmap.vue +434 -0
  9. trigo-web/app/src/components/mcts/MCTSDataLoader.vue +283 -0
  10. trigo-web/app/src/components/mcts/MCTSMoveNavigation.vue +148 -0
  11. trigo-web/app/src/components/mcts/MCTSStatisticsPanel.vue +221 -0
  12. trigo-web/app/src/components/mcts/MCTSTreeVisualization.vue +407 -0
  13. trigo-web/app/src/composables/useRoomHash.ts +55 -0
  14. trigo-web/app/src/composables/useSocket.ts +206 -7
  15. trigo-web/app/src/composables/useTrigoAgent.ts +15 -10
  16. trigo-web/app/src/data/defaultNicknames.ts +140 -0
  17. trigo-web/app/src/router/index.ts +12 -0
  18. trigo-web/app/src/stores/gameStore.ts +70 -0
  19. trigo-web/app/src/stores/mctsStore.ts +179 -0
  20. trigo-web/app/src/stores/playerStore.ts +211 -0
  21. trigo-web/app/src/styles/test-pages.scss +554 -0
  22. trigo-web/app/src/types/mcts.ts +52 -0
  23. trigo-web/app/src/utils/mctsColorScale.ts +184 -0
  24. trigo-web/app/src/utils/mctsDataParser.ts +174 -0
  25. trigo-web/app/src/utils/storage.ts +3 -0
  26. trigo-web/app/src/views/MCTSAnalysisView.vue +176 -0
  27. trigo-web/app/src/views/OnnxTestView.vue +3 -3
  28. trigo-web/app/src/views/SocketTestView.vue +932 -0
  29. trigo-web/app/src/views/TrigoAgentTestView.vue +3 -3
  30. trigo-web/app/src/views/TrigoTreeTestView.vue +15 -4
  31. trigo-web/app/src/views/TrigoView.vue +771 -134
  32. trigo-web/app/vite.config.ts +4 -2
  33. trigo-web/backend/.env +9 -3
  34. trigo-web/backend/.env.local +2 -0
  35. trigo-web/backend/package-lock.json +498 -0
  36. trigo-web/backend/package.json +3 -1
  37. trigo-web/backend/src/server.ts +53 -10
  38. trigo-web/backend/src/services/gameManager.ts +113 -5
  39. trigo-web/backend/src/sockets/gameSocket.ts +350 -44
  40. trigo-web/inc/config.ts +181 -0
  41. trigo-web/inc/mctsAgent.ts +855 -0
  42. trigo-web/inc/modelInferencer.ts +60 -5
  43. trigo-web/inc/trigo/game.ts +149 -23
  44. trigo-web/inc/trigoAgent.ts +3 -1
  45. trigo-web/inc/trigoEvaluationAgent.ts +266 -0
  46. trigo-web/inc/trigoTreeAgent.ts +198 -77
  47. trigo-web/package-lock.json +0 -0
  48. trigo-web/package.json +4 -1
  49. trigo-web/public/onnx/{GPT2CausalLM_ep0015_evaluation.onnx → 20251220-trigo-value-llama-l6-h64-251220-value0.02/LlamaCausalLM_ep0036_evaluation.onnx} +2 -2
  50. trigo-web/public/onnx/20251220-trigo-value-llama-l6-h64-251220-value0.02/LlamaCausalLM_ep0036_tree.onnx +3 -0
trigo-web/.claude/agents/agentlog-updater.md ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: agentlog-updater
3
+ description: Use this agent when a mini-milestone has been accomplished in the Trigo project and the development history needs to be documented in agentlog.md. This includes after completing features, fixing significant bugs, making architectural changes, or finishing logical chunks of work. Examples:\n\n<example>\nContext: User has just finished implementing a new feature for multiplayer room management.\nuser: "I've finished adding the room creation and joining functionality"\nassistant: "Let me document this milestone in agentlog.md using the agentlog-updater agent."\n<commentary>\nSince a development milestone has been reached, use the agentlog-updater agent to properly document it in the project's development history.\n</commentary>\n</example>\n\n<example>\nContext: User mentions completing work on 3D board rendering improvements.\nuser: "完成了3D棋盘渲染的优化" (Chinese: "Completed optimization of 3D board rendering")\nassistant: "I'll use the agentlog-updater agent to document this milestone in agentlog.md with proper English translation and formatting."\n<commentary>\nA milestone has been reached and needs documentation. The agent will handle translation and proper formatting according to project standards.\n</commentary>\n</example>\n\n<example>\nContext: After a series of bug fixes and code refactoring.\nuser: "Can you update the agentlog with what we just did?"\nassistant: "I'll launch the agentlog-updater agent to review our recent conversation and document the mini-milestone in agentlog.md."\n<commentary>\nUser explicitly requests agentlog update, triggering the specialized agent for this task.\n</commentary>\n</example>
4
+ tools: Glob, Grep, Read, WebFetch, TodoWrite, WebSearch, BashOutput, Edit, Write, NotebookEdit
5
+ model: haiku
6
+ color: green
7
+ ---
8
+
9
+ You are the Agentlog Documentation Specialist, an expert technical writer specialized in maintaining clear, concise development histories for software projects. Your singular focus is documenting mini-milestones in the Trigo project's agentlog.md file.
10
+
11
+ ## Your Core Responsibilities
12
+
13
+ 1. **Analyze Recent Conversations**: Review the conversation history to identify the completed mini-milestone, extracting key accomplishments, decisions made, and technical details.
14
+
15
+ 2. **Format User Requests Properly**:
16
+ - Start user requests with `> ` prefix
17
+ - Translate non-English text to English while preserving technical terms
18
+ - Fix any typos or grammar errors
19
+ - Keep the request concise but complete (1-2 sentences typically)
20
+ - Maintain the user's intent and technical accuracy
21
+
22
+ 3. **Create Structured Agent Responses**:
23
+ - Enclose all agent response content within `<details>` and `</details>` tags
24
+ - Add a `<summary>` tag with a concise, descriptive title (5-10 words)
25
+ - Within the details block, document:
26
+ * What was accomplished
27
+ * Key technical decisions or approaches taken
28
+ * Any significant challenges or learnings
29
+ * Files or components affected
30
+ - Use clear, professional technical writing
31
+ - Format code references with backticks
32
+ - Use bullet points for lists
33
+ - Keep paragraphs focused and scannable
34
+
35
+ 4. **Maintain Consistency**:
36
+ - Study the existing agentlog.md format before writing
37
+ - Match the tone, style, and level of detail of previous entries
38
+ - Use the same heading structure and formatting conventions
39
+ - Ensure chronological ordering
40
+
41
+ 5. **Write in English Always**: All documentation must be in English, regardless of the original conversation language. Translate accurately while preserving technical precision.
42
+
43
+ ## Quality Standards
44
+
45
+ - **Conciseness**: Every word should add value. Remove redundancy.
46
+ - **Clarity**: Technical details should be understandable to future developers
47
+ - **Completeness**: Capture the essence of what was accomplished without overwhelming detail
48
+ - **Accuracy**: Preserve technical terms, file paths, and technical decisions exactly
49
+ - **Professionalism**: Use formal technical writing style appropriate for project documentation
50
+
51
+ ## Your Workflow
52
+
53
+ 1. Read the entire recent conversation to understand the milestone
54
+ 2. Review existing agentlog.md entries to match style and format
55
+ 3. Extract the user's core request, translating and cleaning as needed
56
+ 4. Synthesize the agent's work into a well-structured details block
57
+ 5. Present the formatted entry for review before committing
58
+ 6. Update agentlog.md with the new entry in chronological order
59
+
60
+ ## Format Template
61
+
62
+ ```markdown
63
+ > [User's request in English, concise, typos fixed]
64
+
65
+ <details>
66
+ <summary>[Concise title of what was accomplished]</summary>
67
+
68
+ [Well-structured documentation of the work, including:
69
+ - What was done
70
+ - Key technical details
71
+ - Files/components affected
72
+ - Any important decisions or learnings]
73
+
74
+ </details>
75
+ ```
76
+
77
+ You are meticulous about formatting, translation accuracy, and maintaining documentation consistency. Every entry you create should be a valuable reference for understanding the project's evolution.
trigo-web/.env ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Unified Environment Configuration for Trigo Web
2
+ # This file is used by frontend (Vite), backend (Express), and tools (Node scripts)
3
+ #
4
+ # LOCAL OVERRIDES:
5
+ # Create .env.local (not committed to git) to override any values below
6
+ # Example: cp .env.local.example .env.local
7
+ #
8
+ # Loading order: .env → .env.local (overrides)
9
+ #
10
+ # See .env.local.example for common override scenarios
11
+
12
+ # ============================================================================
13
+ # Frontend Configuration (Vite - requires VITE_ prefix)
14
+ # ============================================================================
15
+
16
+ # Backend Server URL
17
+ VITE_SERVER_URL=http://localhost:8157
18
+
19
+ # Vite Dev Server Configuration
20
+ VITE_HOST=0.0.0.0
21
+ VITE_PORT=5173
22
+
23
+ # ONNX Model Paths (relative to /public directory)
24
+ # Evaluation mode model - predicts position value
25
+ VITE_ONNX_EVALUATION_MODEL=/onnx/20251230-trigo-value-llama-l6-h64-it2_251221-value0.01-pretrain/LlamaCausalLM_ep0036_evaluation.onnx
26
+
27
+ # Tree mode model - generates move trees
28
+ VITE_ONNX_TREE_MODEL=/onnx/20251230-trigo-value-llama-l6-h64-it2_251221-value0.01-pretrain/LlamaCausalLM_ep0036_tree.onnx
29
+
30
+
31
+ # ============================================================================
32
+ # Backend Configuration (Express Server)
33
+ # ============================================================================
34
+
35
+ # Server port (HTTP and Socket.io)
36
+ PORT=3000
37
+
38
+ # Frontend URL (used for CORS)
39
+ CLIENT_URL=http://localhost:5173
40
+
41
+ # Environment mode
42
+ NODE_ENV=development
43
+
44
+
45
+ # ============================================================================
46
+ # Tools Configuration (Node scripts - tools/ directory)
47
+ # ============================================================================
48
+
49
+ # ONNX Model Paths (relative to project root)
50
+ # Evaluation mode model - predicts position value
51
+ ONNX_EVALUATION_MODEL=./public/onnx/20251204-trigo-value-gpt2-l6-h64-251125-lr500/GPT2CausalLM_ep0019_evaluation.onnx
52
+
53
+ # Tree mode model - generates move trees
54
+ ONNX_TREE_MODEL=./public/onnx/20251204-trigo-value-gpt2-l6-h64-251125-lr500/GPT2CausalLM_ep0019_tree.onnx
55
+
56
+ # ONNX Runtime Performance Configuration
57
+ # See docs/onnx-threading-configuration.md for detailed tuning guide
58
+
59
+ # Intra-operator parallelism (threads within a single operator)
60
+ # Recommended: 4 for most systems, higher for large models
61
+ # ONNX_INTRA_OP_NUM_THREADS=4
62
+ #ONNX_INTRA_OP_NUM_THREADS=28
63
+
64
+ # Inter-operator parallelism (threads across operators)
65
+ # Recommended: 1-2 for sequential models, higher for complex graphs
66
+ # ONNX_INTER_OP_NUM_THREADS=2
67
+ ONNX_INTER_OP_NUM_THREADS=28
68
+
69
+ # Graph optimization level
70
+ # Options: "disabled", "basic", "extended", "all" (default: "all")
71
+ # ONNX_GRAPH_OPTIMIZATION_LEVEL=all
72
+
73
+ # Memory optimization settings
74
+ # ONNX_ENABLE_CPU_MEM_ARENA=true
75
+ # ONNX_ENABLE_MEM_PATTERN=true
76
+
trigo-web/.env.local.example ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Local Environment Overrides
2
+ #
3
+ # This file is for local development overrides and is NOT committed to git.
4
+ # Copy this file to .env.local and customize as needed.
5
+ #
6
+ # Usage:
7
+ # cp .env.local.example .env.local
8
+ # # Edit .env.local with your local settings
9
+ #
10
+ # Any variables set here will override the values in .env
11
+
12
+
13
+ # ==============================================================================
14
+ # Example: Override ONNX Threading Configuration
15
+ # ==============================================================================
16
+
17
+ # Uncomment and adjust for your CPU:
18
+ # ONNX_INTRA_OP_NUM_THREADS=8
19
+ # ONNX_INTER_OP_NUM_THREADS=4
20
+
21
+
22
+ # ==============================================================================
23
+ # Example: Use Different Model Versions for Testing
24
+ # ==============================================================================
25
+
26
+ # ONNX_EVALUATION_MODEL=./public/onnx/experimental/model_v2_evaluation.onnx
27
+ # ONNX_TREE_MODEL=./public/onnx/experimental/model_v2_tree.onnx
28
+
29
+
30
+ # ==============================================================================
31
+ # Example: Frontend Development Settings
32
+ # ==============================================================================
33
+
34
+ # VITE_PORT=3000
35
+ # VITE_SERVER_URL=http://192.168.1.100:3000
36
+
37
+
38
+ # ==============================================================================
39
+ # Example: Backend Development Settings
40
+ # ==============================================================================
41
+
42
+ # PORT=4000
43
+ # CLIENT_URL=http://localhost:3000
trigo-web/app/.env CHANGED
@@ -1,3 +1,8 @@
1
- VITE_SERVER_URL=http://localhost:3000
2
- VITE_HOST=0.0.0.0
3
- VITE_PORT=5173
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # Frontend Configuration
3
+ # ============================================================================
4
+ # This file is DEPRECATED - all configuration is now in the root .env file
5
+ # Vite is configured to load from ../. env (repository root)
6
+ #
7
+ # To configure the frontend, edit: ../. env
8
+ # ============================================================================
trigo-web/app/package-lock.json CHANGED
@@ -8,8 +8,8 @@
8
  "name": "trigo-app",
9
  "version": "0.0.0",
10
  "dependencies": {
11
- "onnxruntime-common": "^1.23.2",
12
- "onnxruntime-web": "^1.23.2",
13
  "pinia": "^2.1.6",
14
  "socket.io-client": "^4.5.2",
15
  "three": "^0.156.1",
@@ -17,6 +17,7 @@
17
  "vue-router": "^4.2.4"
18
  },
19
  "devDependencies": {
 
20
  "@types/three": "^0.156.0",
21
  "@vitejs/plugin-vue": "^5.2.4",
22
  "sass-embedded": "^1.93.2",
@@ -742,60 +743,6 @@
742
  "url": "https://opencollective.com/parcel"
743
  }
744
  },
745
- "node_modules/@protobufjs/aspromise": {
746
- "version": "1.1.2",
747
- "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
748
- "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="
749
- },
750
- "node_modules/@protobufjs/base64": {
751
- "version": "1.1.2",
752
- "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
753
- "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
754
- },
755
- "node_modules/@protobufjs/codegen": {
756
- "version": "2.0.4",
757
- "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
758
- "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="
759
- },
760
- "node_modules/@protobufjs/eventemitter": {
761
- "version": "1.1.0",
762
- "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
763
- "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="
764
- },
765
- "node_modules/@protobufjs/fetch": {
766
- "version": "1.1.0",
767
- "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
768
- "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
769
- "dependencies": {
770
- "@protobufjs/aspromise": "^1.1.1",
771
- "@protobufjs/inquire": "^1.1.0"
772
- }
773
- },
774
- "node_modules/@protobufjs/float": {
775
- "version": "1.0.2",
776
- "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
777
- "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="
778
- },
779
- "node_modules/@protobufjs/inquire": {
780
- "version": "1.1.0",
781
- "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
782
- "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="
783
- },
784
- "node_modules/@protobufjs/path": {
785
- "version": "1.1.2",
786
- "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
787
- "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="
788
- },
789
- "node_modules/@protobufjs/pool": {
790
- "version": "1.1.0",
791
- "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
792
- "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="
793
- },
794
- "node_modules/@protobufjs/utf8": {
795
- "version": "1.1.0",
796
- "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
797
- "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
798
- },
799
  "node_modules/@rollup/rollup-android-arm-eabi": {
800
  "version": "4.52.5",
801
  "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz",
@@ -1087,16 +1034,278 @@
1087
  "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
1088
  "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
1089
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
  "node_modules/@types/estree": {
1091
  "version": "1.0.8",
1092
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
1093
  "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1094
  "dev": true
1095
  },
 
 
 
 
 
 
1096
  "node_modules/@types/node": {
1097
  "version": "24.10.1",
1098
  "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
1099
  "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
 
 
 
1100
  "dependencies": {
1101
  "undici-types": "~7.16.0"
1102
  }
@@ -1356,11 +1565,389 @@
1356
  "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
1357
  "dev": true
1358
  },
 
 
 
 
 
 
 
 
1359
  "node_modules/csstype": {
1360
  "version": "3.1.3",
1361
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
1362
  "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
1363
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1364
  "node_modules/de-indent": {
1365
  "version": "1.0.2",
1366
  "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@@ -1383,6 +1970,14 @@
1383
  }
1384
  }
1385
  },
 
 
 
 
 
 
 
 
1386
  "node_modules/detect-libc": {
1387
  "version": "1.0.3",
1388
  "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@@ -1489,11 +2084,6 @@
1489
  "node": ">=8"
1490
  }
1491
  },
1492
- "node_modules/flatbuffers": {
1493
- "version": "25.9.23",
1494
- "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
1495
- "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="
1496
- },
1497
  "node_modules/fsevents": {
1498
  "version": "2.3.3",
1499
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1508,11 +2098,6 @@
1508
  "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1509
  }
1510
  },
1511
- "node_modules/guid-typescript": {
1512
- "version": "1.0.9",
1513
- "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
1514
- "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="
1515
- },
1516
  "node_modules/has-flag": {
1517
  "version": "4.0.0",
1518
  "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -1531,12 +2116,31 @@
1531
  "he": "bin/he"
1532
  }
1533
  },
 
 
 
 
 
 
 
 
 
 
 
1534
  "node_modules/immutable": {
1535
  "version": "5.1.4",
1536
  "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
1537
  "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
1538
  "dev": true
1539
  },
 
 
 
 
 
 
 
 
1540
  "node_modules/is-extglob": {
1541
  "version": "2.1.1",
1542
  "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -1570,11 +2174,6 @@
1570
  "node": ">=0.12.0"
1571
  }
1572
  },
1573
- "node_modules/long": {
1574
- "version": "5.3.2",
1575
- "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
1576
- "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
1577
- },
1578
  "node_modules/magic-string": {
1579
  "version": "0.30.19",
1580
  "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
@@ -1653,24 +2252,6 @@
1653
  "dev": true,
1654
  "optional": true
1655
  },
1656
- "node_modules/onnxruntime-common": {
1657
- "version": "1.23.2",
1658
- "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.23.2.tgz",
1659
- "integrity": "sha512-5LFsC9Dukzp2WV6kNHYLNzp8sT6V02IubLCbzw2Xd6X5GOlr65gAX6xiJwyi2URJol/s71gaQLC5F2C25AAR2w=="
1660
- },
1661
- "node_modules/onnxruntime-web": {
1662
- "version": "1.23.2",
1663
- "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.23.2.tgz",
1664
- "integrity": "sha512-T09JUtMn+CZLk3mFwqiH0lgQf+4S7+oYHHtk6uhaYAAJI95bTcKi5bOOZYwORXfS/RLZCjDDEXGWIuOCAFlEjg==",
1665
- "dependencies": {
1666
- "flatbuffers": "^25.1.24",
1667
- "guid-typescript": "^1.0.9",
1668
- "long": "^5.2.3",
1669
- "onnxruntime-common": "1.23.2",
1670
- "platform": "^1.3.6",
1671
- "protobufjs": "^7.2.4"
1672
- }
1673
- },
1674
  "node_modules/path-browserify": {
1675
  "version": "1.0.1",
1676
  "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1716,11 +2297,6 @@
1716
  }
1717
  }
1718
  },
1719
- "node_modules/platform": {
1720
- "version": "1.3.6",
1721
- "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
1722
- "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="
1723
- },
1724
  "node_modules/postcss": {
1725
  "version": "8.5.6",
1726
  "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -1748,29 +2324,6 @@
1748
  "node": "^10 || ^12 || >=14"
1749
  }
1750
  },
1751
- "node_modules/protobufjs": {
1752
- "version": "7.5.4",
1753
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
1754
- "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
1755
- "hasInstallScript": true,
1756
- "dependencies": {
1757
- "@protobufjs/aspromise": "^1.1.2",
1758
- "@protobufjs/base64": "^1.1.2",
1759
- "@protobufjs/codegen": "^2.0.4",
1760
- "@protobufjs/eventemitter": "^1.1.0",
1761
- "@protobufjs/fetch": "^1.1.0",
1762
- "@protobufjs/float": "^1.0.2",
1763
- "@protobufjs/inquire": "^1.1.0",
1764
- "@protobufjs/path": "^1.1.2",
1765
- "@protobufjs/pool": "^1.1.0",
1766
- "@protobufjs/utf8": "^1.1.0",
1767
- "@types/node": ">=13.7.0",
1768
- "long": "^5.0.0"
1769
- },
1770
- "engines": {
1771
- "node": ">=12.0.0"
1772
- }
1773
- },
1774
  "node_modules/readdirp": {
1775
  "version": "4.1.2",
1776
  "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -1785,6 +2338,11 @@
1785
  "url": "https://paulmillr.com/funding/"
1786
  }
1787
  },
 
 
 
 
 
1788
  "node_modules/rollup": {
1789
  "version": "4.52.5",
1790
  "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
@@ -1826,6 +2384,11 @@
1826
  "fsevents": "~2.3.2"
1827
  }
1828
  },
 
 
 
 
 
1829
  "node_modules/rxjs": {
1830
  "version": "7.8.2",
1831
  "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -1835,6 +2398,11 @@
1835
  "tslib": "^2.1.0"
1836
  }
1837
  },
 
 
 
 
 
1838
  "node_modules/sass": {
1839
  "version": "1.93.2",
1840
  "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
@@ -2296,7 +2864,10 @@
2296
  "node_modules/undici-types": {
2297
  "version": "7.16.0",
2298
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
2299
- "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
 
 
 
2300
  },
2301
  "node_modules/varint": {
2302
  "version": "6.0.0",
 
8
  "name": "trigo-app",
9
  "version": "0.0.0",
10
  "dependencies": {
11
+ "d3": "^7.9.0",
12
+ "d3-scale-chromatic": "^3.1.0",
13
  "pinia": "^2.1.6",
14
  "socket.io-client": "^4.5.2",
15
  "three": "^0.156.1",
 
17
  "vue-router": "^4.2.4"
18
  },
19
  "devDependencies": {
20
+ "@types/d3": "^7.4.3",
21
  "@types/three": "^0.156.0",
22
  "@vitejs/plugin-vue": "^5.2.4",
23
  "sass-embedded": "^1.93.2",
 
743
  "url": "https://opencollective.com/parcel"
744
  }
745
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
746
  "node_modules/@rollup/rollup-android-arm-eabi": {
747
  "version": "4.52.5",
748
  "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz",
 
1034
  "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
1035
  "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
1036
  },
1037
+ "node_modules/@types/d3": {
1038
+ "version": "7.4.3",
1039
+ "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
1040
+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
1041
+ "dev": true,
1042
+ "dependencies": {
1043
+ "@types/d3-array": "*",
1044
+ "@types/d3-axis": "*",
1045
+ "@types/d3-brush": "*",
1046
+ "@types/d3-chord": "*",
1047
+ "@types/d3-color": "*",
1048
+ "@types/d3-contour": "*",
1049
+ "@types/d3-delaunay": "*",
1050
+ "@types/d3-dispatch": "*",
1051
+ "@types/d3-drag": "*",
1052
+ "@types/d3-dsv": "*",
1053
+ "@types/d3-ease": "*",
1054
+ "@types/d3-fetch": "*",
1055
+ "@types/d3-force": "*",
1056
+ "@types/d3-format": "*",
1057
+ "@types/d3-geo": "*",
1058
+ "@types/d3-hierarchy": "*",
1059
+ "@types/d3-interpolate": "*",
1060
+ "@types/d3-path": "*",
1061
+ "@types/d3-polygon": "*",
1062
+ "@types/d3-quadtree": "*",
1063
+ "@types/d3-random": "*",
1064
+ "@types/d3-scale": "*",
1065
+ "@types/d3-scale-chromatic": "*",
1066
+ "@types/d3-selection": "*",
1067
+ "@types/d3-shape": "*",
1068
+ "@types/d3-time": "*",
1069
+ "@types/d3-time-format": "*",
1070
+ "@types/d3-timer": "*",
1071
+ "@types/d3-transition": "*",
1072
+ "@types/d3-zoom": "*"
1073
+ }
1074
+ },
1075
+ "node_modules/@types/d3-array": {
1076
+ "version": "3.2.2",
1077
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
1078
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
1079
+ "dev": true
1080
+ },
1081
+ "node_modules/@types/d3-axis": {
1082
+ "version": "3.0.6",
1083
+ "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
1084
+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
1085
+ "dev": true,
1086
+ "dependencies": {
1087
+ "@types/d3-selection": "*"
1088
+ }
1089
+ },
1090
+ "node_modules/@types/d3-brush": {
1091
+ "version": "3.0.6",
1092
+ "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
1093
+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
1094
+ "dev": true,
1095
+ "dependencies": {
1096
+ "@types/d3-selection": "*"
1097
+ }
1098
+ },
1099
+ "node_modules/@types/d3-chord": {
1100
+ "version": "3.0.6",
1101
+ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
1102
+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
1103
+ "dev": true
1104
+ },
1105
+ "node_modules/@types/d3-color": {
1106
+ "version": "3.1.3",
1107
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
1108
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
1109
+ "dev": true
1110
+ },
1111
+ "node_modules/@types/d3-contour": {
1112
+ "version": "3.0.6",
1113
+ "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
1114
+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
1115
+ "dev": true,
1116
+ "dependencies": {
1117
+ "@types/d3-array": "*",
1118
+ "@types/geojson": "*"
1119
+ }
1120
+ },
1121
+ "node_modules/@types/d3-delaunay": {
1122
+ "version": "6.0.4",
1123
+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
1124
+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
1125
+ "dev": true
1126
+ },
1127
+ "node_modules/@types/d3-dispatch": {
1128
+ "version": "3.0.7",
1129
+ "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
1130
+ "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
1131
+ "dev": true
1132
+ },
1133
+ "node_modules/@types/d3-drag": {
1134
+ "version": "3.0.7",
1135
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
1136
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
1137
+ "dev": true,
1138
+ "dependencies": {
1139
+ "@types/d3-selection": "*"
1140
+ }
1141
+ },
1142
+ "node_modules/@types/d3-dsv": {
1143
+ "version": "3.0.7",
1144
+ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
1145
+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
1146
+ "dev": true
1147
+ },
1148
+ "node_modules/@types/d3-ease": {
1149
+ "version": "3.0.2",
1150
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
1151
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
1152
+ "dev": true
1153
+ },
1154
+ "node_modules/@types/d3-fetch": {
1155
+ "version": "3.0.7",
1156
+ "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
1157
+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
1158
+ "dev": true,
1159
+ "dependencies": {
1160
+ "@types/d3-dsv": "*"
1161
+ }
1162
+ },
1163
+ "node_modules/@types/d3-force": {
1164
+ "version": "3.0.10",
1165
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
1166
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
1167
+ "dev": true
1168
+ },
1169
+ "node_modules/@types/d3-format": {
1170
+ "version": "3.0.4",
1171
+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
1172
+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
1173
+ "dev": true
1174
+ },
1175
+ "node_modules/@types/d3-geo": {
1176
+ "version": "3.1.0",
1177
+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
1178
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
1179
+ "dev": true,
1180
+ "dependencies": {
1181
+ "@types/geojson": "*"
1182
+ }
1183
+ },
1184
+ "node_modules/@types/d3-hierarchy": {
1185
+ "version": "3.1.7",
1186
+ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
1187
+ "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
1188
+ "dev": true
1189
+ },
1190
+ "node_modules/@types/d3-interpolate": {
1191
+ "version": "3.0.4",
1192
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
1193
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
1194
+ "dev": true,
1195
+ "dependencies": {
1196
+ "@types/d3-color": "*"
1197
+ }
1198
+ },
1199
+ "node_modules/@types/d3-path": {
1200
+ "version": "3.1.1",
1201
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
1202
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
1203
+ "dev": true
1204
+ },
1205
+ "node_modules/@types/d3-polygon": {
1206
+ "version": "3.0.2",
1207
+ "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
1208
+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
1209
+ "dev": true
1210
+ },
1211
+ "node_modules/@types/d3-quadtree": {
1212
+ "version": "3.0.6",
1213
+ "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
1214
+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
1215
+ "dev": true
1216
+ },
1217
+ "node_modules/@types/d3-random": {
1218
+ "version": "3.0.3",
1219
+ "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
1220
+ "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
1221
+ "dev": true
1222
+ },
1223
+ "node_modules/@types/d3-scale": {
1224
+ "version": "4.0.9",
1225
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
1226
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
1227
+ "dev": true,
1228
+ "dependencies": {
1229
+ "@types/d3-time": "*"
1230
+ }
1231
+ },
1232
+ "node_modules/@types/d3-scale-chromatic": {
1233
+ "version": "3.1.0",
1234
+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
1235
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
1236
+ "dev": true
1237
+ },
1238
+ "node_modules/@types/d3-selection": {
1239
+ "version": "3.0.11",
1240
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
1241
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
1242
+ "dev": true
1243
+ },
1244
+ "node_modules/@types/d3-shape": {
1245
+ "version": "3.1.7",
1246
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
1247
+ "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
1248
+ "dev": true,
1249
+ "dependencies": {
1250
+ "@types/d3-path": "*"
1251
+ }
1252
+ },
1253
+ "node_modules/@types/d3-time": {
1254
+ "version": "3.0.4",
1255
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
1256
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
1257
+ "dev": true
1258
+ },
1259
+ "node_modules/@types/d3-time-format": {
1260
+ "version": "4.0.3",
1261
+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
1262
+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
1263
+ "dev": true
1264
+ },
1265
+ "node_modules/@types/d3-timer": {
1266
+ "version": "3.0.2",
1267
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
1268
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
1269
+ "dev": true
1270
+ },
1271
+ "node_modules/@types/d3-transition": {
1272
+ "version": "3.0.9",
1273
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
1274
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
1275
+ "dev": true,
1276
+ "dependencies": {
1277
+ "@types/d3-selection": "*"
1278
+ }
1279
+ },
1280
+ "node_modules/@types/d3-zoom": {
1281
+ "version": "3.0.8",
1282
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
1283
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
1284
+ "dev": true,
1285
+ "dependencies": {
1286
+ "@types/d3-interpolate": "*",
1287
+ "@types/d3-selection": "*"
1288
+ }
1289
+ },
1290
  "node_modules/@types/estree": {
1291
  "version": "1.0.8",
1292
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
1293
  "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1294
  "dev": true
1295
  },
1296
+ "node_modules/@types/geojson": {
1297
+ "version": "7946.0.16",
1298
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
1299
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
1300
+ "dev": true
1301
+ },
1302
  "node_modules/@types/node": {
1303
  "version": "24.10.1",
1304
  "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
1305
  "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
1306
+ "dev": true,
1307
+ "optional": true,
1308
+ "peer": true,
1309
  "dependencies": {
1310
  "undici-types": "~7.16.0"
1311
  }
 
1565
  "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
1566
  "dev": true
1567
  },
1568
+ "node_modules/commander": {
1569
+ "version": "7.2.0",
1570
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
1571
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
1572
+ "engines": {
1573
+ "node": ">= 10"
1574
+ }
1575
+ },
1576
  "node_modules/csstype": {
1577
  "version": "3.1.3",
1578
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
1579
  "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
1580
  },
1581
+ "node_modules/d3": {
1582
+ "version": "7.9.0",
1583
+ "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
1584
+ "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
1585
+ "dependencies": {
1586
+ "d3-array": "3",
1587
+ "d3-axis": "3",
1588
+ "d3-brush": "3",
1589
+ "d3-chord": "3",
1590
+ "d3-color": "3",
1591
+ "d3-contour": "4",
1592
+ "d3-delaunay": "6",
1593
+ "d3-dispatch": "3",
1594
+ "d3-drag": "3",
1595
+ "d3-dsv": "3",
1596
+ "d3-ease": "3",
1597
+ "d3-fetch": "3",
1598
+ "d3-force": "3",
1599
+ "d3-format": "3",
1600
+ "d3-geo": "3",
1601
+ "d3-hierarchy": "3",
1602
+ "d3-interpolate": "3",
1603
+ "d3-path": "3",
1604
+ "d3-polygon": "3",
1605
+ "d3-quadtree": "3",
1606
+ "d3-random": "3",
1607
+ "d3-scale": "4",
1608
+ "d3-scale-chromatic": "3",
1609
+ "d3-selection": "3",
1610
+ "d3-shape": "3",
1611
+ "d3-time": "3",
1612
+ "d3-time-format": "4",
1613
+ "d3-timer": "3",
1614
+ "d3-transition": "3",
1615
+ "d3-zoom": "3"
1616
+ },
1617
+ "engines": {
1618
+ "node": ">=12"
1619
+ }
1620
+ },
1621
+ "node_modules/d3-array": {
1622
+ "version": "3.2.4",
1623
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
1624
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
1625
+ "dependencies": {
1626
+ "internmap": "1 - 2"
1627
+ },
1628
+ "engines": {
1629
+ "node": ">=12"
1630
+ }
1631
+ },
1632
+ "node_modules/d3-axis": {
1633
+ "version": "3.0.0",
1634
+ "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
1635
+ "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
1636
+ "engines": {
1637
+ "node": ">=12"
1638
+ }
1639
+ },
1640
+ "node_modules/d3-brush": {
1641
+ "version": "3.0.0",
1642
+ "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
1643
+ "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
1644
+ "dependencies": {
1645
+ "d3-dispatch": "1 - 3",
1646
+ "d3-drag": "2 - 3",
1647
+ "d3-interpolate": "1 - 3",
1648
+ "d3-selection": "3",
1649
+ "d3-transition": "3"
1650
+ },
1651
+ "engines": {
1652
+ "node": ">=12"
1653
+ }
1654
+ },
1655
+ "node_modules/d3-chord": {
1656
+ "version": "3.0.1",
1657
+ "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
1658
+ "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
1659
+ "dependencies": {
1660
+ "d3-path": "1 - 3"
1661
+ },
1662
+ "engines": {
1663
+ "node": ">=12"
1664
+ }
1665
+ },
1666
+ "node_modules/d3-color": {
1667
+ "version": "3.1.0",
1668
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
1669
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
1670
+ "engines": {
1671
+ "node": ">=12"
1672
+ }
1673
+ },
1674
+ "node_modules/d3-contour": {
1675
+ "version": "4.0.2",
1676
+ "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
1677
+ "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
1678
+ "dependencies": {
1679
+ "d3-array": "^3.2.0"
1680
+ },
1681
+ "engines": {
1682
+ "node": ">=12"
1683
+ }
1684
+ },
1685
+ "node_modules/d3-delaunay": {
1686
+ "version": "6.0.4",
1687
+ "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
1688
+ "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
1689
+ "dependencies": {
1690
+ "delaunator": "5"
1691
+ },
1692
+ "engines": {
1693
+ "node": ">=12"
1694
+ }
1695
+ },
1696
+ "node_modules/d3-dispatch": {
1697
+ "version": "3.0.1",
1698
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
1699
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
1700
+ "engines": {
1701
+ "node": ">=12"
1702
+ }
1703
+ },
1704
+ "node_modules/d3-drag": {
1705
+ "version": "3.0.0",
1706
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
1707
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
1708
+ "dependencies": {
1709
+ "d3-dispatch": "1 - 3",
1710
+ "d3-selection": "3"
1711
+ },
1712
+ "engines": {
1713
+ "node": ">=12"
1714
+ }
1715
+ },
1716
+ "node_modules/d3-dsv": {
1717
+ "version": "3.0.1",
1718
+ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
1719
+ "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
1720
+ "dependencies": {
1721
+ "commander": "7",
1722
+ "iconv-lite": "0.6",
1723
+ "rw": "1"
1724
+ },
1725
+ "bin": {
1726
+ "csv2json": "bin/dsv2json.js",
1727
+ "csv2tsv": "bin/dsv2dsv.js",
1728
+ "dsv2dsv": "bin/dsv2dsv.js",
1729
+ "dsv2json": "bin/dsv2json.js",
1730
+ "json2csv": "bin/json2dsv.js",
1731
+ "json2dsv": "bin/json2dsv.js",
1732
+ "json2tsv": "bin/json2dsv.js",
1733
+ "tsv2csv": "bin/dsv2dsv.js",
1734
+ "tsv2json": "bin/dsv2json.js"
1735
+ },
1736
+ "engines": {
1737
+ "node": ">=12"
1738
+ }
1739
+ },
1740
+ "node_modules/d3-ease": {
1741
+ "version": "3.0.1",
1742
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
1743
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
1744
+ "engines": {
1745
+ "node": ">=12"
1746
+ }
1747
+ },
1748
+ "node_modules/d3-fetch": {
1749
+ "version": "3.0.1",
1750
+ "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
1751
+ "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
1752
+ "dependencies": {
1753
+ "d3-dsv": "1 - 3"
1754
+ },
1755
+ "engines": {
1756
+ "node": ">=12"
1757
+ }
1758
+ },
1759
+ "node_modules/d3-force": {
1760
+ "version": "3.0.0",
1761
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
1762
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
1763
+ "dependencies": {
1764
+ "d3-dispatch": "1 - 3",
1765
+ "d3-quadtree": "1 - 3",
1766
+ "d3-timer": "1 - 3"
1767
+ },
1768
+ "engines": {
1769
+ "node": ">=12"
1770
+ }
1771
+ },
1772
+ "node_modules/d3-format": {
1773
+ "version": "3.1.0",
1774
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
1775
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
1776
+ "engines": {
1777
+ "node": ">=12"
1778
+ }
1779
+ },
1780
+ "node_modules/d3-geo": {
1781
+ "version": "3.1.1",
1782
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
1783
+ "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
1784
+ "dependencies": {
1785
+ "d3-array": "2.5.0 - 3"
1786
+ },
1787
+ "engines": {
1788
+ "node": ">=12"
1789
+ }
1790
+ },
1791
+ "node_modules/d3-hierarchy": {
1792
+ "version": "3.1.2",
1793
+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
1794
+ "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
1795
+ "engines": {
1796
+ "node": ">=12"
1797
+ }
1798
+ },
1799
+ "node_modules/d3-interpolate": {
1800
+ "version": "3.0.1",
1801
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
1802
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
1803
+ "dependencies": {
1804
+ "d3-color": "1 - 3"
1805
+ },
1806
+ "engines": {
1807
+ "node": ">=12"
1808
+ }
1809
+ },
1810
+ "node_modules/d3-path": {
1811
+ "version": "3.1.0",
1812
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
1813
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
1814
+ "engines": {
1815
+ "node": ">=12"
1816
+ }
1817
+ },
1818
+ "node_modules/d3-polygon": {
1819
+ "version": "3.0.1",
1820
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
1821
+ "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
1822
+ "engines": {
1823
+ "node": ">=12"
1824
+ }
1825
+ },
1826
+ "node_modules/d3-quadtree": {
1827
+ "version": "3.0.1",
1828
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
1829
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
1830
+ "engines": {
1831
+ "node": ">=12"
1832
+ }
1833
+ },
1834
+ "node_modules/d3-random": {
1835
+ "version": "3.0.1",
1836
+ "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
1837
+ "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
1838
+ "engines": {
1839
+ "node": ">=12"
1840
+ }
1841
+ },
1842
+ "node_modules/d3-scale": {
1843
+ "version": "4.0.2",
1844
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
1845
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
1846
+ "dependencies": {
1847
+ "d3-array": "2.10.0 - 3",
1848
+ "d3-format": "1 - 3",
1849
+ "d3-interpolate": "1.2.0 - 3",
1850
+ "d3-time": "2.1.1 - 3",
1851
+ "d3-time-format": "2 - 4"
1852
+ },
1853
+ "engines": {
1854
+ "node": ">=12"
1855
+ }
1856
+ },
1857
+ "node_modules/d3-scale-chromatic": {
1858
+ "version": "3.1.0",
1859
+ "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
1860
+ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
1861
+ "dependencies": {
1862
+ "d3-color": "1 - 3",
1863
+ "d3-interpolate": "1 - 3"
1864
+ },
1865
+ "engines": {
1866
+ "node": ">=12"
1867
+ }
1868
+ },
1869
+ "node_modules/d3-selection": {
1870
+ "version": "3.0.0",
1871
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
1872
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
1873
+ "engines": {
1874
+ "node": ">=12"
1875
+ }
1876
+ },
1877
+ "node_modules/d3-shape": {
1878
+ "version": "3.2.0",
1879
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
1880
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
1881
+ "dependencies": {
1882
+ "d3-path": "^3.1.0"
1883
+ },
1884
+ "engines": {
1885
+ "node": ">=12"
1886
+ }
1887
+ },
1888
+ "node_modules/d3-time": {
1889
+ "version": "3.1.0",
1890
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
1891
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
1892
+ "dependencies": {
1893
+ "d3-array": "2 - 3"
1894
+ },
1895
+ "engines": {
1896
+ "node": ">=12"
1897
+ }
1898
+ },
1899
+ "node_modules/d3-time-format": {
1900
+ "version": "4.1.0",
1901
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
1902
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
1903
+ "dependencies": {
1904
+ "d3-time": "1 - 3"
1905
+ },
1906
+ "engines": {
1907
+ "node": ">=12"
1908
+ }
1909
+ },
1910
+ "node_modules/d3-timer": {
1911
+ "version": "3.0.1",
1912
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
1913
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
1914
+ "engines": {
1915
+ "node": ">=12"
1916
+ }
1917
+ },
1918
+ "node_modules/d3-transition": {
1919
+ "version": "3.0.1",
1920
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
1921
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
1922
+ "dependencies": {
1923
+ "d3-color": "1 - 3",
1924
+ "d3-dispatch": "1 - 3",
1925
+ "d3-ease": "1 - 3",
1926
+ "d3-interpolate": "1 - 3",
1927
+ "d3-timer": "1 - 3"
1928
+ },
1929
+ "engines": {
1930
+ "node": ">=12"
1931
+ },
1932
+ "peerDependencies": {
1933
+ "d3-selection": "2 - 3"
1934
+ }
1935
+ },
1936
+ "node_modules/d3-zoom": {
1937
+ "version": "3.0.0",
1938
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
1939
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
1940
+ "dependencies": {
1941
+ "d3-dispatch": "1 - 3",
1942
+ "d3-drag": "2 - 3",
1943
+ "d3-interpolate": "1 - 3",
1944
+ "d3-selection": "2 - 3",
1945
+ "d3-transition": "2 - 3"
1946
+ },
1947
+ "engines": {
1948
+ "node": ">=12"
1949
+ }
1950
+ },
1951
  "node_modules/de-indent": {
1952
  "version": "1.0.2",
1953
  "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
 
1970
  }
1971
  }
1972
  },
1973
+ "node_modules/delaunator": {
1974
+ "version": "5.0.1",
1975
+ "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
1976
+ "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
1977
+ "dependencies": {
1978
+ "robust-predicates": "^3.0.2"
1979
+ }
1980
+ },
1981
  "node_modules/detect-libc": {
1982
  "version": "1.0.3",
1983
  "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
 
2084
  "node": ">=8"
2085
  }
2086
  },
 
 
 
 
 
2087
  "node_modules/fsevents": {
2088
  "version": "2.3.3",
2089
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
 
2098
  "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
2099
  }
2100
  },
 
 
 
 
 
2101
  "node_modules/has-flag": {
2102
  "version": "4.0.0",
2103
  "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
 
2116
  "he": "bin/he"
2117
  }
2118
  },
2119
+ "node_modules/iconv-lite": {
2120
+ "version": "0.6.3",
2121
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
2122
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
2123
+ "dependencies": {
2124
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
2125
+ },
2126
+ "engines": {
2127
+ "node": ">=0.10.0"
2128
+ }
2129
+ },
2130
  "node_modules/immutable": {
2131
  "version": "5.1.4",
2132
  "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
2133
  "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
2134
  "dev": true
2135
  },
2136
+ "node_modules/internmap": {
2137
+ "version": "2.0.3",
2138
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
2139
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
2140
+ "engines": {
2141
+ "node": ">=12"
2142
+ }
2143
+ },
2144
  "node_modules/is-extglob": {
2145
  "version": "2.1.1",
2146
  "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
 
2174
  "node": ">=0.12.0"
2175
  }
2176
  },
 
 
 
 
 
2177
  "node_modules/magic-string": {
2178
  "version": "0.30.19",
2179
  "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
 
2252
  "dev": true,
2253
  "optional": true
2254
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2255
  "node_modules/path-browserify": {
2256
  "version": "1.0.1",
2257
  "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
 
2297
  }
2298
  }
2299
  },
 
 
 
 
 
2300
  "node_modules/postcss": {
2301
  "version": "8.5.6",
2302
  "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
 
2324
  "node": "^10 || ^12 || >=14"
2325
  }
2326
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2327
  "node_modules/readdirp": {
2328
  "version": "4.1.2",
2329
  "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
 
2338
  "url": "https://paulmillr.com/funding/"
2339
  }
2340
  },
2341
+ "node_modules/robust-predicates": {
2342
+ "version": "3.0.2",
2343
+ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
2344
+ "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="
2345
+ },
2346
  "node_modules/rollup": {
2347
  "version": "4.52.5",
2348
  "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
 
2384
  "fsevents": "~2.3.2"
2385
  }
2386
  },
2387
+ "node_modules/rw": {
2388
+ "version": "1.3.3",
2389
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
2390
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
2391
+ },
2392
  "node_modules/rxjs": {
2393
  "version": "7.8.2",
2394
  "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
 
2398
  "tslib": "^2.1.0"
2399
  }
2400
  },
2401
+ "node_modules/safer-buffer": {
2402
+ "version": "2.1.2",
2403
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
2404
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
2405
+ },
2406
  "node_modules/sass": {
2407
  "version": "1.93.2",
2408
  "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
 
2864
  "node_modules/undici-types": {
2865
  "version": "7.16.0",
2866
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
2867
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
2868
+ "dev": true,
2869
+ "optional": true,
2870
+ "peer": true
2871
  },
2872
  "node_modules/varint": {
2873
  "version": "6.0.0",
trigo-web/app/package.json CHANGED
@@ -10,6 +10,8 @@
10
  "preview": "vite preview"
11
  },
12
  "dependencies": {
 
 
13
  "pinia": "^2.1.6",
14
  "socket.io-client": "^4.5.2",
15
  "three": "^0.156.1",
@@ -18,6 +20,7 @@
18
  "onnxruntime-web": "^1.23.2"
19
  },
20
  "devDependencies": {
 
21
  "@types/three": "^0.156.0",
22
  "@vitejs/plugin-vue": "^5.2.4",
23
  "sass-embedded": "^1.93.2",
 
10
  "preview": "vite preview"
11
  },
12
  "dependencies": {
13
+ "d3": "^7.9.0",
14
+ "d3-scale-chromatic": "^3.1.0",
15
  "pinia": "^2.1.6",
16
  "socket.io-client": "^4.5.2",
17
  "three": "^0.156.1",
 
20
  "onnxruntime-web": "^1.23.2"
21
  },
22
  "devDependencies": {
23
+ "@types/d3": "^7.4.3",
24
  "@types/three": "^0.156.0",
25
  "@vitejs/plugin-vue": "^5.2.4",
26
  "sass-embedded": "^1.93.2",
trigo-web/app/src/components/InlineNicknameEditor.vue ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="inline-nickname-editor" :class="{ editable, editing, [playerColor || 'neutral']: true }">
3
+ <!-- Display mode -->
4
+ <div
5
+ v-if="!editing"
6
+ class="nickname-display"
7
+ :title="editable ? 'Click to edit nickname' : ''"
8
+ @click="startEdit"
9
+ >
10
+ <span class="stone-icon" :class="playerColor"></span>
11
+ <span class="nickname-text">{{ displayNickname }}</span>
12
+ <span v-if="editable" class="edit-icon">✏️</span>
13
+ </div>
14
+
15
+ <!-- Edit mode -->
16
+ <div v-else class="nickname-edit">
17
+ <span class="stone-icon" :class="playerColor"></span>
18
+ <input
19
+ ref="inputRef"
20
+ v-model="editValue"
21
+ type="text"
22
+ class="nickname-input"
23
+ :class="{ error: hasError }"
24
+ :maxlength="20"
25
+ :title="inputTooltip"
26
+ @keydown.enter="confirmEdit"
27
+ @keydown.esc="cancelEdit"
28
+ @blur="confirmEdit"
29
+ />
30
+ <span v-if="hasError" class="error-icon" :title="errorMessage">⚠️</span>
31
+ </div>
32
+
33
+ <!-- Character count and help text when editing -->
34
+ <div v-if="editing" class="edit-info">
35
+ <span class="help-text">Enter to save • Esc to cancel</span>
36
+ <span class="char-count">{{ editValue.length }}/20</span>
37
+ </div>
38
+ </div>
39
+ </template>
40
+
41
+ <script setup lang="ts">
42
+ import { ref, computed, watch, nextTick } from "vue";
43
+
44
+ const props = defineProps<{
45
+ nickname: string;
46
+ editable: boolean;
47
+ playerColor: "black" | "white" | null;
48
+ }>();
49
+
50
+ const emit = defineEmits<{
51
+ (e: "update", nickname: string): void;
52
+ }>();
53
+
54
+ const editing = ref(false);
55
+ const editValue = ref(props.nickname);
56
+ const errorMessage = ref("");
57
+ const inputRef = ref<HTMLInputElement | null>(null);
58
+
59
+ const displayNickname = computed(() => props.nickname || "Guest");
60
+ const hasError = computed(() => errorMessage.value !== "");
61
+ const inputTooltip = computed(() =>
62
+ "3-20 characters, letters/numbers/spaces only"
63
+ );
64
+
65
+ function validateNickname(nickname: string): { valid: boolean; error: string } {
66
+ const trimmed = nickname.trim();
67
+
68
+ if (trimmed.length < 3) return { valid: false, error: "Must be 3+ chars" };
69
+ if (trimmed.length > 20) return { valid: false, error: "Max 20 chars" };
70
+ if (!/^[a-zA-Z0-9 ]+$/.test(trimmed)) return { valid: false, error: "Letters, numbers, spaces only" };
71
+ if (trimmed !== nickname) return { valid: false, error: "No leading/trailing spaces" };
72
+
73
+ return { valid: true, error: "" };
74
+ }
75
+
76
+ function startEdit() {
77
+ if (!props.editable) return;
78
+
79
+ editing.value = true;
80
+ editValue.value = props.nickname;
81
+ errorMessage.value = "";
82
+
83
+ nextTick(() => {
84
+ inputRef.value?.focus();
85
+ inputRef.value?.select();
86
+ });
87
+ }
88
+
89
+ function confirmEdit() {
90
+ if (!editing.value) return;
91
+
92
+ const trimmed = editValue.value.trim();
93
+
94
+ if (trimmed === props.nickname) {
95
+ cancelEdit();
96
+ return;
97
+ }
98
+
99
+ const validation = validateNickname(trimmed);
100
+ if (!validation.valid) {
101
+ errorMessage.value = validation.error;
102
+ return;
103
+ }
104
+
105
+ editing.value = false;
106
+ errorMessage.value = "";
107
+ emit("update", trimmed);
108
+ }
109
+
110
+ function cancelEdit() {
111
+ editing.value = false;
112
+ editValue.value = props.nickname;
113
+ errorMessage.value = "";
114
+ }
115
+
116
+ watch(
117
+ () => props.nickname,
118
+ (newNickname) => {
119
+ if (!editing.value) {
120
+ editValue.value = newNickname;
121
+ }
122
+ }
123
+ );
124
+ </script>
125
+
126
+ <style lang="scss" scoped>
127
+ .inline-nickname-editor {
128
+ display: flex;
129
+ flex-direction: column;
130
+ gap: 0.25rem;
131
+
132
+ &.editable .nickname-display {
133
+ cursor: pointer;
134
+ transition: background-color 0.2s ease;
135
+
136
+ &:hover {
137
+ background-color: rgba(255, 255, 255, 0.05);
138
+ }
139
+ }
140
+
141
+ .nickname-display,
142
+ .nickname-edit {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 0.5rem;
146
+ padding: 0.5rem;
147
+ border-radius: 6px;
148
+ background-color: rgba(0, 0, 0, 0.2);
149
+ }
150
+
151
+ .stone-icon {
152
+ width: 20px;
153
+ height: 20px;
154
+ border-radius: 50%;
155
+ flex-shrink: 0;
156
+
157
+ &.black {
158
+ background-color: #2c2c2c;
159
+ border: 2px solid #fff;
160
+ }
161
+
162
+ &.white {
163
+ background-color: #f0f0f0;
164
+ border: 2px solid #000;
165
+ }
166
+ }
167
+
168
+ .nickname-text {
169
+ font-weight: 600;
170
+ color: #e0e0e0;
171
+ }
172
+
173
+ .edit-icon {
174
+ opacity: 0;
175
+ transition: opacity 0.2s ease;
176
+ font-size: 0.8rem;
177
+ }
178
+
179
+ &.editable:hover .edit-icon {
180
+ opacity: 0.6;
181
+ }
182
+
183
+ .nickname-input {
184
+ flex: 1;
185
+ background-color: rgba(255, 255, 255, 0.1);
186
+ border: 2px solid #60a5fa;
187
+ border-radius: 4px;
188
+ padding: 0.25rem 0.5rem;
189
+ color: #e0e0e0;
190
+ font-weight: 600;
191
+ font-size: 0.95rem;
192
+ outline: none;
193
+
194
+ &.error {
195
+ border-color: #ef4444;
196
+ }
197
+
198
+ &:focus {
199
+ background-color: rgba(255, 255, 255, 0.15);
200
+ }
201
+ }
202
+
203
+ .error-icon {
204
+ font-size: 1rem;
205
+ cursor: help;
206
+ }
207
+
208
+ .edit-info {
209
+ display: flex;
210
+ justify-content: space-between;
211
+ align-items: center;
212
+ padding: 0 0.5rem;
213
+ font-size: 0.75rem;
214
+ }
215
+
216
+ .help-text {
217
+ color: #60a5fa;
218
+ font-style: italic;
219
+ }
220
+
221
+ .char-count {
222
+ color: #9ca3af;
223
+ }
224
+ }
225
+ </style>
trigo-web/app/src/components/mcts/MCTSBoardHeatmap.vue ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="board-heatmap">
3
+ <div v-if="!hasData" class="no-data">
4
+ <p>No data loaded. Please upload files to begin.</p>
5
+ </div>
6
+
7
+ <div v-else class="heatmap-wrapper">
8
+ <!-- SVG Board -->
9
+ <svg
10
+ :width="svgWidth"
11
+ :height="svgHeight"
12
+ @mouseleave="onMouseLeave"
13
+ class="board-svg"
14
+ >
15
+ <!-- Grid lines -->
16
+ <g class="grid-lines">
17
+ <!-- Vertical lines -->
18
+ <line
19
+ v-for="i in shape.x + 1"
20
+ :key="'v' + i"
21
+ :x1="(i - 1) * cellSize + margin"
22
+ :y1="margin"
23
+ :x2="(i - 1) * cellSize + margin"
24
+ :y2="boardHeight + margin"
25
+ stroke="#505050"
26
+ stroke-width="1"
27
+ />
28
+ <!-- Horizontal lines -->
29
+ <line
30
+ v-for="j in shape.y + 1"
31
+ :key="'h' + j"
32
+ :x1="margin"
33
+ :y1="(j - 1) * cellSize + margin"
34
+ :x2="boardWidth + margin"
35
+ :y2="(j - 1) * cellSize + margin"
36
+ stroke="#505050"
37
+ stroke-width="1"
38
+ />
39
+ </g>
40
+
41
+ <!-- Heatmap cells -->
42
+ <g class="heatmap-cells">
43
+ <rect
44
+ v-for="stat in statistics"
45
+ :key="stat.actionKey"
46
+ v-show="stat.position"
47
+ :x="stat.position ? stat.position.x * cellSize + margin : 0"
48
+ :y="stat.position ? stat.position.y * cellSize + margin : 0"
49
+ :width="cellSize"
50
+ :height="cellSize"
51
+ :fill="getColor(stat)"
52
+ :opacity="0.7"
53
+ :class="{ selected: isSelected(stat.actionKey) }"
54
+ @mouseenter="onCellHover(stat)"
55
+ @click="onCellClick(stat)"
56
+ class="heatmap-cell"
57
+ />
58
+ </g>
59
+
60
+ <!-- Stones (current game state) -->
61
+ <g class="stones">
62
+ <circle
63
+ v-for="(stone, idx) in currentStones"
64
+ :key="idx"
65
+ :cx="(stone.x + 0.5) * cellSize + margin"
66
+ :cy="(stone.y + 0.5) * cellSize + margin"
67
+ :r="cellSize * 0.35"
68
+ :fill="stone.color === 1 ? '#070707' : '#f0f0f0'"
69
+ :stroke="stone.color === 1 ? '#202020' : '#d0d0d0'"
70
+ stroke-width="1"
71
+ class="stone"
72
+ />
73
+ </g>
74
+
75
+ <!-- Last move marker -->
76
+ <g class="last-move-marker" v-if="lastMovePosition">
77
+ <circle
78
+ :cx="(lastMovePosition.x + 0.5) * cellSize + margin"
79
+ :cy="(lastMovePosition.y + 0.5) * cellSize + margin"
80
+ :r="cellSize * 0.15"
81
+ fill="#e94560"
82
+ class="last-move-circle"
83
+ />
84
+ </g>
85
+
86
+ <!-- Coordinate labels -->
87
+ <g class="coord-labels">
88
+ <!-- Column labels (A, B, C...) -->
89
+ <text
90
+ v-for="i in shape.x"
91
+ :key="'col-' + i"
92
+ :x="(i - 0.5) * cellSize + margin"
93
+ :y="svgHeight - margin / 3"
94
+ text-anchor="middle"
95
+ class="coord-label"
96
+ >
97
+ {{ String.fromCharCode(64 + i) }}
98
+ </text>
99
+ <!-- Row labels (1, 2, 3...) -->
100
+ <text
101
+ v-for="j in shape.y"
102
+ :key="'row-' + j"
103
+ :x="margin / 3"
104
+ :y="(j - 0.5) * cellSize + margin"
105
+ text-anchor="middle"
106
+ dominant-baseline="middle"
107
+ class="coord-label"
108
+ >
109
+ {{ j }}
110
+ </text>
111
+ </g>
112
+ </svg>
113
+
114
+ <!-- Legend -->
115
+ <div class="legend">
116
+ <div class="legend-title">{{ legendTitle }}</div>
117
+ <div class="legend-gradient">
118
+ <div
119
+ v-for="stop in legendStops"
120
+ :key="stop.value"
121
+ :style="{ backgroundColor: stop.color }"
122
+ class="legend-stop"
123
+ ></div>
124
+ </div>
125
+ <div class="legend-labels">
126
+ <span class="legend-label-min">{{ minLabel }}</span>
127
+ <span class="legend-label-max">{{ maxLabel }}</span>
128
+ </div>
129
+ </div>
130
+
131
+ <!-- Tooltip -->
132
+ <div
133
+ v-if="hoveredCell"
134
+ class="tooltip"
135
+ :style="{ left: tooltipX + 'px', top: tooltipY + 'px' }"
136
+ >
137
+ <div class="tooltip-position">{{ formatPosition(hoveredCell.position) }}</div>
138
+ <div class="tooltip-stat">N: {{ hoveredCell.N }}</div>
139
+ <div class="tooltip-stat">π: {{ (hoveredCell.pi * 100).toFixed(2) }}%</div>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ </template>
144
+
145
+ <script setup lang="ts">
146
+ import { ref, computed, watch } from "vue";
147
+ import { useMCTSStore } from "@/stores/mctsStore";
148
+ import type { MCTSMoveStatistic } from "@/types/mcts";
149
+ import { getColorForStatistic, generateLegendStops, getStatisticLabel } from "@/utils/mctsColorScale";
150
+ import { formatPosition } from "@/utils/mctsDataParser";
151
+ import { StepType } from "../../../../inc/trigo/game";
152
+
153
+ const mctsStore = useMCTSStore();
154
+
155
+ // Constants
156
+ const cellSize = 50;
157
+ const margin = 40;
158
+
159
+ // Computed
160
+ const hasData = computed(() => mctsStore.hasData);
161
+
162
+ const currentMoveData = computed(() => mctsStore.currentMoveData);
163
+
164
+ const statistics = computed(() => mctsStore.currentStatistics);
165
+
166
+ const shape = computed(() => {
167
+ if (!currentMoveData.value) return { x: 5, y: 5, z: 1 };
168
+ return currentMoveData.value.gameState.getShape();
169
+ });
170
+
171
+ const boardWidth = computed(() => shape.value.x * cellSize);
172
+ const boardHeight = computed(() => shape.value.y * cellSize);
173
+ const svgWidth = computed(() => boardWidth.value + margin * 2);
174
+ const svgHeight = computed(() => boardHeight.value + margin * 2);
175
+
176
+ const currentStones = computed(() => {
177
+ if (!currentMoveData.value) return [];
178
+
179
+ const board = currentMoveData.value.gameState.getBoard();
180
+ const stones: Array<{ x: number; y: number; color: number }> = [];
181
+
182
+ for (let x = 0; x < shape.value.x; x++) {
183
+ for (let y = 0; y < shape.value.y; y++) {
184
+ const color = board[x][y][0]; // z=0 for 2D boards
185
+ if (color !== 0) {
186
+ stones.push({ x, y, color });
187
+ }
188
+ }
189
+ }
190
+
191
+ return stones;
192
+ });
193
+
194
+ // Get last move position for highlighting
195
+ const lastMovePosition = computed(() => {
196
+ if (!currentMoveData.value || currentMoveData.value.moveNumber === 0) return null;
197
+
198
+ const history = currentMoveData.value.gameState.getHistory();
199
+ if (history.length === 0) return null;
200
+
201
+ const lastMove = history[history.length - 1];
202
+
203
+ // Skip pass moves or moves without position
204
+ if (lastMove.type !== StepType.DROP || !lastMove.position) return null;
205
+
206
+ // Return position for 2D boards (z=0)
207
+ return {
208
+ x: lastMove.position.x,
209
+ y: lastMove.position.y,
210
+ z: 0
211
+ };
212
+ });
213
+
214
+ // Color scale
215
+ const minValue = computed(() => {
216
+ if (statistics.value.length === 0) return 0;
217
+ const values = statistics.value.map((s) => {
218
+ return mctsStore.selectedStatistic === "N" ? s.N : s.pi;
219
+ });
220
+ return Math.min(...values);
221
+ });
222
+
223
+ const maxValue = computed(() => {
224
+ if (statistics.value.length === 0) return 1;
225
+ const values = statistics.value.map((s) => {
226
+ return mctsStore.selectedStatistic === "N" ? s.N : s.pi;
227
+ });
228
+ return Math.max(...values);
229
+ });
230
+
231
+ // Legend
232
+ const legendTitle = computed(() => getStatisticLabel(mctsStore.selectedStatistic));
233
+
234
+ const legendStops = computed(() => {
235
+ return generateLegendStops(mctsStore.selectedStatistic, minValue.value, maxValue.value, 5);
236
+ });
237
+
238
+ const minLabel = computed(() => {
239
+ if (mctsStore.selectedStatistic === "pi") {
240
+ return `${(minValue.value * 100).toFixed(1)}%`;
241
+ }
242
+ return Math.round(minValue.value).toString();
243
+ });
244
+
245
+ const maxLabel = computed(() => {
246
+ if (mctsStore.selectedStatistic === "pi") {
247
+ return `${(maxValue.value * 100).toFixed(1)}%`;
248
+ }
249
+ return Math.round(maxValue.value).toString();
250
+ });
251
+
252
+ // Hover state
253
+ const hoveredCell = ref<MCTSMoveStatistic | null>(null);
254
+ const tooltipX = ref(0);
255
+ const tooltipY = ref(0);
256
+
257
+ // Functions
258
+ function getColor(stat: MCTSMoveStatistic): string {
259
+ const value = mctsStore.selectedStatistic === "N" ? stat.N : stat.pi;
260
+ return getColorForStatistic(
261
+ value,
262
+ mctsStore.selectedStatistic,
263
+ minValue.value,
264
+ maxValue.value,
265
+ mctsStore.colorScale
266
+ );
267
+ }
268
+
269
+ function isSelected(actionKey: string): boolean {
270
+ return mctsStore.selectedActionKey === actionKey;
271
+ }
272
+
273
+ function onCellHover(stat: MCTSMoveStatistic) {
274
+ hoveredCell.value = stat;
275
+ // Tooltip position will be updated via mouse position
276
+ }
277
+
278
+ function onCellClick(stat: MCTSMoveStatistic) {
279
+ mctsStore.selectAction(stat.actionKey);
280
+ }
281
+
282
+ function onMouseLeave() {
283
+ hoveredCell.value = null;
284
+ }
285
+
286
+ // Track mouse position for tooltip
287
+ function onMouseMove(event: MouseEvent) {
288
+ tooltipX.value = event.clientX + 10;
289
+ tooltipY.value = event.clientY + 10;
290
+ }
291
+
292
+ // Set up mouse tracking
293
+ if (typeof window !== "undefined") {
294
+ window.addEventListener("mousemove", onMouseMove);
295
+ }
296
+ </script>
297
+
298
+ <style scoped lang="scss">
299
+ .board-heatmap {
300
+ width: 100%;
301
+ height: 100%;
302
+ display: flex;
303
+ flex-direction: column;
304
+ align-items: center;
305
+ justify-content: center;
306
+ }
307
+
308
+ .no-data {
309
+ display: flex;
310
+ justify-content: center;
311
+ align-items: center;
312
+ height: 100%;
313
+ color: #606060;
314
+ font-size: 14px;
315
+ }
316
+
317
+ .heatmap-wrapper {
318
+ display: flex;
319
+ flex-direction: column;
320
+ align-items: center;
321
+ gap: 20px;
322
+ max-width: 100%;
323
+ max-height: 100%;
324
+ }
325
+
326
+ .board-svg {
327
+ border-radius: 4px;
328
+ }
329
+
330
+ .heatmap-cell {
331
+ cursor: pointer;
332
+ transition: opacity 0.2s;
333
+
334
+ &:hover {
335
+ opacity: 0.9 !important;
336
+ stroke: #4a90e2;
337
+ stroke-width: 2;
338
+ }
339
+
340
+ &.selected {
341
+ stroke: #e94560;
342
+ stroke-width: 3;
343
+ }
344
+ }
345
+
346
+ .stone {
347
+ pointer-events: none;
348
+ }
349
+
350
+ .last-move-circle {
351
+ pointer-events: none;
352
+ animation: pulse 1.5s ease-in-out infinite;
353
+ }
354
+
355
+ @keyframes pulse {
356
+ 0%, 100% {
357
+ opacity: 1;
358
+ }
359
+ 50% {
360
+ opacity: 0.4;
361
+ }
362
+ }
363
+
364
+ .coord-label {
365
+ fill: #808080;
366
+ font-size: 11px;
367
+ user-select: none;
368
+ }
369
+
370
+ /* ============================================================================
371
+ Legend
372
+ ============================================================================ */
373
+
374
+ .legend {
375
+ display: flex;
376
+ flex-direction: column;
377
+ gap: 8px;
378
+ width: 300px;
379
+ }
380
+
381
+ .legend-title {
382
+ font-size: 13px;
383
+ font-weight: 500;
384
+ color: #c0c0c0;
385
+ text-align: center;
386
+ }
387
+
388
+ .legend-gradient {
389
+ display: flex;
390
+ height: 20px;
391
+ border-radius: 3px;
392
+ overflow: hidden;
393
+ border: 1px solid #404040;
394
+ }
395
+
396
+ .legend-stop {
397
+ flex: 1;
398
+ }
399
+
400
+ .legend-labels {
401
+ display: flex;
402
+ justify-content: space-between;
403
+ font-size: 11px;
404
+ color: #808080;
405
+ }
406
+
407
+ /* ============================================================================
408
+ Tooltip
409
+ ============================================================================ */
410
+
411
+ .tooltip {
412
+ position: fixed;
413
+ background-color: rgba(0, 0, 0, 0.9);
414
+ border: 1px solid #4a90e2;
415
+ border-radius: 4px;
416
+ padding: 8px 12px;
417
+ font-size: 12px;
418
+ color: #e0e0e0;
419
+ pointer-events: none;
420
+ z-index: 1000;
421
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
422
+ }
423
+
424
+ .tooltip-position {
425
+ font-weight: 600;
426
+ color: #4a90e2;
427
+ margin-bottom: 4px;
428
+ }
429
+
430
+ .tooltip-stat {
431
+ color: #c0c0c0;
432
+ line-height: 1.4;
433
+ }
434
+ </style>
trigo-web/app/src/components/mcts/MCTSDataLoader.vue ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="mcts-data-loader">
3
+ <div class="file-inputs">
4
+ <!-- TGN File Input -->
5
+ <div class="file-input-group">
6
+ <label for="tgn-file" class="file-label">
7
+ TGN Game File
8
+ <span class="file-status" :class="{ ready: tgnFile }">
9
+ {{ tgnFile ? `✓ ${tgnFile.name}` : "Not selected" }}
10
+ </span>
11
+ </label>
12
+ <input
13
+ id="tgn-file"
14
+ type="file"
15
+ accept=".tgn"
16
+ @change="onTGNFileChange"
17
+ class="file-input"
18
+ />
19
+ </div>
20
+
21
+ <!-- Visit Counts JSON Input -->
22
+ <div class="file-input-group">
23
+ <label for="json-file" class="file-label">
24
+ Visit Counts JSON
25
+ <span class="file-status" :class="{ ready: jsonFile }">
26
+ {{ jsonFile ? `✓ ${jsonFile.name}` : "Not selected" }}
27
+ </span>
28
+ </label>
29
+ <input
30
+ id="json-file"
31
+ type="file"
32
+ accept=".json"
33
+ @change="onJSONFileChange"
34
+ class="file-input"
35
+ />
36
+ </div>
37
+ </div>
38
+
39
+ <!-- Load Button -->
40
+ <button
41
+ @click="loadData"
42
+ :disabled="!canLoad || loading"
43
+ class="btn-load"
44
+ :class="{ loading }"
45
+ >
46
+ {{ loading ? "Loading..." : "Load Data" }}
47
+ </button>
48
+
49
+ <!-- Status Messages -->
50
+ <div v-if="loading" class="status-message loading">
51
+ <div class="spinner"></div>
52
+ <span>Parsing data...</span>
53
+ </div>
54
+
55
+ <div v-if="error" class="status-message error">
56
+ <strong>Error:</strong> {{ error }}
57
+ </div>
58
+
59
+ <div v-if="success" class="status-message success">
60
+ <strong>Success:</strong> Loaded {{ movesCount }} moves from a {{ boardShape }} board
61
+ </div>
62
+ </div>
63
+ </template>
64
+
65
+ <script setup lang="ts">
66
+ import { ref, computed } from "vue";
67
+ import { useMCTSStore } from "@/stores/mctsStore";
68
+ import { parseMCTSData } from "@/utils/mctsDataParser";
69
+
70
+ const mctsStore = useMCTSStore();
71
+
72
+ // File state
73
+ const tgnFile = ref<File | null>(null);
74
+ const jsonFile = ref<File | null>(null);
75
+
76
+ // Loading state
77
+ const loading = ref(false);
78
+ const error = ref<string | null>(null);
79
+ const success = ref(false);
80
+
81
+ // Success data
82
+ const movesCount = ref(0);
83
+ const boardShape = ref("");
84
+
85
+ // Computed
86
+ const canLoad = computed(() => tgnFile.value !== null && jsonFile.value !== null);
87
+
88
+ // Handlers
89
+ function onTGNFileChange(event: Event) {
90
+ const target = event.target as HTMLInputElement;
91
+ tgnFile.value = target.files?.[0] || null;
92
+ clearMessages();
93
+ }
94
+
95
+ function onJSONFileChange(event: Event) {
96
+ const target = event.target as HTMLInputElement;
97
+ jsonFile.value = target.files?.[0] || null;
98
+ clearMessages();
99
+ }
100
+
101
+ function clearMessages() {
102
+ error.value = null;
103
+ success.value = false;
104
+ }
105
+
106
+ async function loadData() {
107
+ if (!canLoad.value) return;
108
+
109
+ loading.value = true;
110
+ clearMessages();
111
+
112
+ try {
113
+ // Read files
114
+ const tgnContent = await tgnFile.value!.text();
115
+ const jsonContent = await jsonFile.value!.text();
116
+
117
+ // Parse data
118
+ const data = await parseMCTSData(tgnContent, jsonContent);
119
+
120
+ // Store data
121
+ mctsStore.setGameHistory(data);
122
+
123
+ // Show success
124
+ success.value = true;
125
+ movesCount.value = data.length;
126
+ const shape = data[0].gameState.getShape();
127
+ boardShape.value = `${shape.x}×${shape.y}×${shape.z}`;
128
+
129
+ console.log(`[MCTS Loader] Successfully loaded ${data.length} moves`);
130
+ } catch (err) {
131
+ error.value = err instanceof Error ? err.message : String(err);
132
+ console.error("[MCTS Loader] Error loading data:", err);
133
+ } finally {
134
+ loading.value = false;
135
+ }
136
+ }
137
+ </script>
138
+
139
+ <style scoped lang="scss">
140
+ .mcts-data-loader {
141
+ display: flex;
142
+ flex-direction: column;
143
+ gap: 15px;
144
+ }
145
+
146
+ .file-inputs {
147
+ display: flex;
148
+ flex-direction: column;
149
+ gap: 12px;
150
+ }
151
+
152
+ .file-input-group {
153
+ display: flex;
154
+ flex-direction: column;
155
+ gap: 6px;
156
+ }
157
+
158
+ .file-label {
159
+ font-size: 13px;
160
+ font-weight: 500;
161
+ color: #c0c0c0;
162
+ display: flex;
163
+ justify-content: space-between;
164
+ align-items: center;
165
+ }
166
+
167
+ .file-status {
168
+ font-size: 11px;
169
+ color: #808080;
170
+ font-weight: normal;
171
+
172
+ &.ready {
173
+ color: #4a90e2;
174
+ }
175
+ }
176
+
177
+ .file-input {
178
+ padding: 8px;
179
+ background-color: #1a1a1a;
180
+ border: 1px solid #404040;
181
+ border-radius: 4px;
182
+ color: #e0e0e0;
183
+ font-size: 13px;
184
+ cursor: pointer;
185
+
186
+ &:hover {
187
+ border-color: #4a90e2;
188
+ }
189
+
190
+ &:focus {
191
+ outline: none;
192
+ border-color: #4a90e2;
193
+ }
194
+
195
+ &::file-selector-button {
196
+ padding: 6px 12px;
197
+ background-color: #2a2a2a;
198
+ border: 1px solid #404040;
199
+ border-radius: 3px;
200
+ color: #e0e0e0;
201
+ font-size: 12px;
202
+ cursor: pointer;
203
+ margin-right: 10px;
204
+
205
+ &:hover {
206
+ background-color: #3a3a3a;
207
+ border-color: #4a90e2;
208
+ }
209
+ }
210
+ }
211
+
212
+ .btn-load {
213
+ padding: 12px 20px;
214
+ background-color: #4a90e2;
215
+ border: none;
216
+ border-radius: 4px;
217
+ color: #ffffff;
218
+ font-size: 14px;
219
+ font-weight: 500;
220
+ cursor: pointer;
221
+ transition: background-color 0.2s, opacity 0.2s;
222
+
223
+ &:hover:not(:disabled) {
224
+ background-color: #357abd;
225
+ }
226
+
227
+ &:active:not(:disabled) {
228
+ background-color: #2a6ba8;
229
+ }
230
+
231
+ &:disabled {
232
+ opacity: 0.5;
233
+ cursor: not-allowed;
234
+ }
235
+
236
+ &.loading {
237
+ opacity: 0.7;
238
+ }
239
+ }
240
+
241
+ .status-message {
242
+ padding: 10px;
243
+ border-radius: 4px;
244
+ font-size: 13px;
245
+ display: flex;
246
+ align-items: center;
247
+ gap: 10px;
248
+
249
+ &.loading {
250
+ background-color: #2a3a4a;
251
+ color: #4a90e2;
252
+ }
253
+
254
+ &.error {
255
+ background-color: #4a2a2a;
256
+ color: #ff6b6b;
257
+ }
258
+
259
+ &.success {
260
+ background-color: #2a4a2a;
261
+ color: #6bff6b;
262
+ }
263
+
264
+ strong {
265
+ font-weight: 600;
266
+ }
267
+ }
268
+
269
+ .spinner {
270
+ width: 16px;
271
+ height: 16px;
272
+ border: 2px solid #4a90e2;
273
+ border-top-color: transparent;
274
+ border-radius: 50%;
275
+ animation: spin 0.8s linear infinite;
276
+ }
277
+
278
+ @keyframes spin {
279
+ to {
280
+ transform: rotate(360deg);
281
+ }
282
+ }
283
+ </style>
trigo-web/app/src/components/mcts/MCTSMoveNavigation.vue ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="mcts-move-navigation">
3
+ <div class="nav-buttons">
4
+ <button @click="mctsStore.firstMove()" :disabled="isFirst" class="nav-btn">⏮ First</button>
5
+ <button @click="mctsStore.prevMove()" :disabled="isFirst" class="nav-btn">◀ Prev</button>
6
+ <button @click="mctsStore.nextMove()" :disabled="isLast" class="nav-btn">Next ▶</button>
7
+ <button @click="mctsStore.lastMove()" :disabled="isLast" class="nav-btn">Last ⏭</button>
8
+ </div>
9
+
10
+ <div class="move-slider">
11
+ <input
12
+ type="range"
13
+ :min="0"
14
+ :max="maxMove"
15
+ :value="currentMove"
16
+ @input="onSliderChange"
17
+ class="slider"
18
+ />
19
+ <span class="move-label">Move {{ currentMove + 1 }} / {{ maxMove + 1 }}</span>
20
+ </div>
21
+
22
+ <div class="move-info">
23
+ <span>Player: <strong>{{ currentPlayer }}</strong></span>
24
+ </div>
25
+ </div>
26
+ </template>
27
+
28
+ <script setup lang="ts">
29
+ import { computed } from "vue";
30
+ import { useMCTSStore } from "@/stores/mctsStore";
31
+
32
+ const mctsStore = useMCTSStore();
33
+
34
+ const currentMove = computed(() => mctsStore.currentMoveIndex);
35
+ const maxMove = computed(() => mctsStore.maxMoveIndex);
36
+ const isFirst = computed(() => currentMove.value === 0);
37
+ const isLast = computed(() => currentMove.value === maxMove.value);
38
+
39
+ const currentPlayer = computed(() => {
40
+ const moveData = mctsStore.currentMoveData;
41
+ return moveData ? moveData.player : "-";
42
+ });
43
+
44
+ function onSliderChange(event: Event) {
45
+ const target = event.target as HTMLInputElement;
46
+ mctsStore.setCurrentMoveIndex(parseInt(target.value, 10));
47
+ }
48
+ </script>
49
+
50
+ <style scoped lang="scss">
51
+ .mcts-move-navigation {
52
+ display: flex;
53
+ flex-direction: column;
54
+ gap: 15px;
55
+ }
56
+
57
+ .nav-buttons {
58
+ display: grid;
59
+ grid-template-columns: 1fr 1fr;
60
+ gap: 8px;
61
+ }
62
+
63
+ .nav-btn {
64
+ padding: 8px 12px;
65
+ background-color: #3a3a3a;
66
+ border: 1px solid #505050;
67
+ border-radius: 4px;
68
+ color: #e0e0e0;
69
+ font-size: 12px;
70
+ cursor: pointer;
71
+ transition: all 0.2s;
72
+
73
+ &:hover:not(:disabled) {
74
+ background-color: #4a4a4a;
75
+ border-color: #4a90e2;
76
+ }
77
+
78
+ &:active:not(:disabled) {
79
+ background-color: #2a2a2a;
80
+ }
81
+
82
+ &:disabled {
83
+ opacity: 0.4;
84
+ cursor: not-allowed;
85
+ }
86
+ }
87
+
88
+ .move-slider {
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: 8px;
92
+ }
93
+
94
+ .slider {
95
+ width: 100%;
96
+ height: 6px;
97
+ border-radius: 3px;
98
+ background-color: #3a3a3a;
99
+ outline: none;
100
+ -webkit-appearance: none;
101
+
102
+ &::-webkit-slider-thumb {
103
+ -webkit-appearance: none;
104
+ appearance: none;
105
+ width: 16px;
106
+ height: 16px;
107
+ border-radius: 50%;
108
+ background-color: #4a90e2;
109
+ cursor: pointer;
110
+
111
+ &:hover {
112
+ background-color: #357abd;
113
+ }
114
+ }
115
+
116
+ &::-moz-range-thumb {
117
+ width: 16px;
118
+ height: 16px;
119
+ border-radius: 50%;
120
+ background-color: #4a90e2;
121
+ cursor: pointer;
122
+ border: none;
123
+
124
+ &:hover {
125
+ background-color: #357abd;
126
+ }
127
+ }
128
+ }
129
+
130
+ .move-label {
131
+ font-size: 13px;
132
+ color: #c0c0c0;
133
+ text-align: center;
134
+ }
135
+
136
+ .move-info {
137
+ padding: 8px;
138
+ background-color: #1a1a1a;
139
+ border-radius: 4px;
140
+ font-size: 12px;
141
+ color: #a0a0a0;
142
+
143
+ strong {
144
+ color: #e0e0e0;
145
+ text-transform: capitalize;
146
+ }
147
+ }
148
+ </style>
trigo-web/app/src/components/mcts/MCTSStatisticsPanel.vue ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="mcts-statistics-panel">
3
+ <div v-if="!hasData" class="no-data">No data</div>
4
+
5
+ <div v-else class="stats-content">
6
+ <div class="stats-summary">
7
+ <div class="stat-item">
8
+ <span class="stat-label">Total visits:</span>
9
+ <span class="stat-value">{{ totalVisits }}</span>
10
+ </div>
11
+ </div>
12
+
13
+ <div class="stats-options">
14
+ <label class="option-label">Display:</label>
15
+ <div class="radio-group">
16
+ <label class="radio-option">
17
+ <input
18
+ type="radio"
19
+ value="N"
20
+ :checked="selectedStat === 'N'"
21
+ @change="onStatChange"
22
+ />
23
+ <span>Visit Count (N)</span>
24
+ </label>
25
+ <label class="radio-option">
26
+ <input
27
+ type="radio"
28
+ value="pi"
29
+ :checked="selectedStat === 'pi'"
30
+ @change="onStatChange"
31
+ />
32
+ <span>Policy (π)</span>
33
+ </label>
34
+ </div>
35
+ </div>
36
+
37
+ <div class="top-moves-table">
38
+ <table>
39
+ <thead>
40
+ <tr>
41
+ <th>#</th>
42
+ <th>Pos</th>
43
+ <th>N</th>
44
+ <th>%</th>
45
+ <th>π</th>
46
+ </tr>
47
+ </thead>
48
+ <tbody>
49
+ <tr
50
+ v-for="(move, idx) in topMoves"
51
+ :key="move.actionKey"
52
+ :class="{ selected: isSelected(move.actionKey) }"
53
+ @click="onMoveClick(move.actionKey)"
54
+ class="move-row"
55
+ >
56
+ <td>{{ idx + 1 }}</td>
57
+ <td>{{ formatPosition(move.position) }}</td>
58
+ <td>{{ move.N }}</td>
59
+ <td>{{ ((move.N / totalVisits) * 100).toFixed(1) }}%</td>
60
+ <td>{{ move.pi.toFixed(3) }}</td>
61
+ </tr>
62
+ </tbody>
63
+ </table>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </template>
68
+
69
+ <script setup lang="ts">
70
+ import { computed } from "vue";
71
+ import { useMCTSStore } from "@/stores/mctsStore";
72
+ import { formatPosition } from "@/utils/mctsDataParser";
73
+ import type { MCTSStatisticType } from "@/types/mcts";
74
+
75
+ const mctsStore = useMCTSStore();
76
+
77
+ const hasData = computed(() => mctsStore.hasData);
78
+ const totalVisits = computed(() => mctsStore.totalVisits);
79
+ const topMoves = computed(() => mctsStore.topMoves);
80
+ const selectedStat = computed(() => mctsStore.selectedStatistic);
81
+
82
+ function isSelected(actionKey: string): boolean {
83
+ return mctsStore.selectedActionKey === actionKey;
84
+ }
85
+
86
+ function onMoveClick(actionKey: string) {
87
+ mctsStore.selectAction(actionKey);
88
+ }
89
+
90
+ function onStatChange(event: Event) {
91
+ const target = event.target as HTMLInputElement;
92
+ mctsStore.setSelectedStatistic(target.value as MCTSStatisticType);
93
+ }
94
+ </script>
95
+
96
+ <style scoped lang="scss">
97
+ .mcts-statistics-panel {
98
+ display: flex;
99
+ flex-direction: column;
100
+ }
101
+
102
+ .no-data {
103
+ text-align: center;
104
+ color: #606060;
105
+ font-size: 12px;
106
+ padding: 20px;
107
+ }
108
+
109
+ .stats-content {
110
+ display: flex;
111
+ flex-direction: column;
112
+ gap: 15px;
113
+ }
114
+
115
+ .stats-summary {
116
+ padding: 10px;
117
+ background-color: #1a1a1a;
118
+ border-radius: 4px;
119
+ }
120
+
121
+ .stat-item {
122
+ display: flex;
123
+ justify-content: space-between;
124
+ font-size: 13px;
125
+ }
126
+
127
+ .stat-label {
128
+ color: #a0a0a0;
129
+ }
130
+
131
+ .stat-value {
132
+ color: #e0e0e0;
133
+ font-weight: 500;
134
+ }
135
+
136
+ .stats-options {
137
+ display: flex;
138
+ flex-direction: column;
139
+ gap: 8px;
140
+ }
141
+
142
+ .option-label {
143
+ font-size: 12px;
144
+ color: #a0a0a0;
145
+ font-weight: 500;
146
+ }
147
+
148
+ .radio-group {
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: 6px;
152
+ }
153
+
154
+ .radio-option {
155
+ display: flex;
156
+ align-items: center;
157
+ gap: 8px;
158
+ font-size: 12px;
159
+ color: #c0c0c0;
160
+ cursor: pointer;
161
+
162
+ input[type="radio"] {
163
+ cursor: pointer;
164
+ }
165
+ }
166
+
167
+ .top-moves-table {
168
+ max-height: 300px;
169
+
170
+ table {
171
+ width: 100%;
172
+ border-collapse: collapse;
173
+ font-size: 12px;
174
+
175
+ thead {
176
+ position: sticky;
177
+ top: 0;
178
+ background-color: #2a2a2a;
179
+ z-index: 1;
180
+
181
+ th {
182
+ padding: 8px 6px;
183
+ text-align: left;
184
+ color: #4a90e2;
185
+ font-weight: 600;
186
+ border-bottom: 1px solid #404040;
187
+ }
188
+ }
189
+
190
+ tbody {
191
+ tr {
192
+ cursor: pointer;
193
+ transition: background-color 0.2s;
194
+
195
+ &:hover {
196
+ background-color: #3a3a3a;
197
+ }
198
+
199
+ &.selected {
200
+ background-color: #4a3030;
201
+
202
+ td {
203
+ color: #e94560;
204
+ font-weight: 500;
205
+ }
206
+ }
207
+
208
+ td {
209
+ padding: 8px 6px;
210
+ color: #c0c0c0;
211
+ border-bottom: 1px solid #303030;
212
+
213
+ &:first-child {
214
+ color: #808080;
215
+ }
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+ </style>
trigo-web/app/src/components/mcts/MCTSTreeVisualization.vue ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="tree-visualization">
3
+ <div v-if="!hasData" class="no-data">
4
+ <p>No data loaded. Please upload files to begin.</p>
5
+ <p class="note">Note: Only root-level statistics available from saved data</p>
6
+ </div>
7
+
8
+ <div v-else class="tree-content">
9
+ <svg
10
+ :width="svgWidth"
11
+ :height="svgHeight"
12
+ :viewBox="`0 0 ${svgWidth} ${svgHeight}`"
13
+ preserveAspectRatio="xMidYMid meet"
14
+ class="tree-svg"
15
+ ref="svgRef"
16
+ >
17
+ <g :transform="`translate(${centerX}, ${rootY})`">
18
+ <!-- Root node -->
19
+ <g class="root-node">
20
+ <circle
21
+ :r="rootRadius"
22
+ fill="#4a90e2"
23
+ stroke="#357abd"
24
+ stroke-width="2"
25
+ class="node-circle"
26
+ />
27
+ <text y="5" text-anchor="middle" class="node-label">Root</text>
28
+ <text y="20" text-anchor="middle" class="node-value">
29
+ {{ totalVisits }} visits
30
+ </text>
31
+ </g>
32
+
33
+ <!-- Links to children -->
34
+ <g class="links">
35
+ <path
36
+ v-for="(child, idx) in visibleChildren"
37
+ :key="child.actionKey"
38
+ :d="getLinkPath(idx)"
39
+ :stroke="getChildColor(child)"
40
+ :stroke-width="getStrokeWidth(child)"
41
+ fill="none"
42
+ opacity="0.6"
43
+ class="tree-link"
44
+ />
45
+ </g>
46
+
47
+ <!-- Child nodes -->
48
+ <g class="child-nodes">
49
+ <g
50
+ v-for="(child, idx) in visibleChildren"
51
+ :key="child.actionKey"
52
+ :transform="getChildTransform(idx)"
53
+ @mouseenter="onChildHover(child)"
54
+ @mouseleave="onChildLeave"
55
+ @click="onChildClick(child)"
56
+ class="child-node"
57
+ :class="{ selected: isSelected(child.actionKey) }"
58
+ >
59
+ <circle
60
+ :r="getChildRadius(child)"
61
+ :fill="getChildColor(child)"
62
+ :stroke="isSelected(child.actionKey) ? '#e94560' : '#2a2a2a'"
63
+ :stroke-width="isSelected(child.actionKey) ? 3 : 1"
64
+ class="node-circle"
65
+ />
66
+ <text y="-15" text-anchor="middle" class="node-label">
67
+ {{ formatPosition(child.position) }}
68
+ </text>
69
+ <text y="0" text-anchor="middle" class="node-value">
70
+ {{ child.N }}
71
+ </text>
72
+ <text y="12" text-anchor="middle" class="node-percent">
73
+ {{ (child.pi * 100).toFixed(1) }}%
74
+ </text>
75
+ </g>
76
+ </g>
77
+ </g>
78
+ </svg>
79
+
80
+ <!-- Settings -->
81
+ <div class="tree-settings">
82
+ <label class="setting-label">
83
+ Show top:
84
+ <input
85
+ type="range"
86
+ min="5"
87
+ max="20"
88
+ :value="topN"
89
+ @input="onTopNChange"
90
+ class="setting-slider"
91
+ />
92
+ <span class="setting-value">{{ topN }} moves</span>
93
+ </label>
94
+ </div>
95
+
96
+ <!-- Tooltip -->
97
+ <div
98
+ v-if="hoveredChild"
99
+ class="tooltip"
100
+ :style="{ left: tooltipX + 'px', top: tooltipY + 'px' }"
101
+ >
102
+ <div class="tooltip-pos">{{ formatPosition(hoveredChild.position) }}</div>
103
+ <div class="tooltip-line">Visit count: {{ hoveredChild.N }}</div>
104
+ <div class="tooltip-line">
105
+ Percentage: {{ ((hoveredChild.N / totalVisits) * 100).toFixed(1) }}%
106
+ </div>
107
+ <div class="tooltip-line">Policy π: {{ hoveredChild.pi.toFixed(3) }}</div>
108
+ </div>
109
+ </div>
110
+ </div>
111
+ </template>
112
+
113
+ <script setup lang="ts">
114
+ import { ref, computed } from "vue";
115
+ import { useMCTSStore } from "@/stores/mctsStore";
116
+ import type { MCTSMoveStatistic } from "@/types/mcts";
117
+ import { formatPosition } from "@/utils/mctsDataParser";
118
+ import { getColorForStatistic } from "@/utils/mctsColorScale";
119
+
120
+ const mctsStore = useMCTSStore();
121
+
122
+ // Layout constants
123
+ const rootRadius = 30;
124
+ const rootY = 50; // Root node Y position
125
+ const childY = 150; // Child nodes Y position
126
+ const padding = 20; // Padding around content
127
+
128
+ // Settings
129
+ const topN = ref(10);
130
+
131
+ // Computed
132
+ const hasData = computed(() => mctsStore.hasData);
133
+ const totalVisits = computed(() => mctsStore.totalVisits);
134
+ const currentStatistics = computed(() => mctsStore.currentStatistics);
135
+
136
+ const visibleChildren = computed(() => {
137
+ return currentStatistics.value
138
+ .filter((stat: MCTSMoveStatistic) => stat.position !== null) // Exclude pass for tree view
139
+ .sort((a: MCTSMoveStatistic, b: MCTSMoveStatistic) => b.N - a.N)
140
+ .slice(0, topN.value);
141
+ });
142
+
143
+ // Calculate max child radius for accurate bounds
144
+ const maxChildRadius = computed(() => {
145
+ if (visibleChildren.value.length === 0) return 15;
146
+ const maxN = Math.max(...visibleChildren.value.map((c: MCTSMoveStatistic) => c.N));
147
+ const minRadius = 15;
148
+ const maxRadius = 35;
149
+ return maxRadius; // Use max possible radius for bounds calculation
150
+ });
151
+
152
+ // Calculate horizontal spread
153
+ const horizontalSpread = computed(() => {
154
+ const count = visibleChildren.value.length;
155
+ if (count === 0) return 100;
156
+ return Math.min(count * 80, 800);
157
+ });
158
+
159
+ // Calculate SVG dimensions based on content
160
+ const svgWidth = computed(() => {
161
+ const calculatedWidth = horizontalSpread.value + padding * 2 + maxChildRadius.value * 2;
162
+ return Math.max(calculatedWidth, 300); // Minimum width of 300px
163
+ });
164
+
165
+ const svgHeight = computed(() => {
166
+ // Root at rootY with rootRadius, children at childY with maxChildRadius
167
+ const calculatedHeight = childY + maxChildRadius.value + padding * 2;
168
+ return Math.max(calculatedHeight, 250); // Minimum height of 250px
169
+ });
170
+
171
+ const centerX = computed(() => svgWidth.value / 2);
172
+
173
+ // Hover state
174
+ const hoveredChild = ref<MCTSMoveStatistic | null>(null);
175
+ const tooltipX = ref(0);
176
+ const tooltipY = ref(0);
177
+
178
+ // Functions
179
+ function getChildTransform(index: number): string {
180
+ const count = visibleChildren.value.length;
181
+ const spread = horizontalSpread.value;
182
+ const startX = -spread / 2;
183
+ const x = startX + (spread / (count - 1 || 1)) * index;
184
+ return `translate(${x}, ${childY})`;
185
+ }
186
+
187
+ function getLinkPath(index: number): string {
188
+ const count = visibleChildren.value.length;
189
+ const spread = horizontalSpread.value;
190
+ const startX = -spread / 2;
191
+ const x = startX + (spread / (count - 1 || 1)) * index;
192
+
193
+ // Curved path from root to child
194
+ return `M 0,${rootRadius} Q 0,${childY / 2} ${x},${childY - getChildRadius(visibleChildren.value[index])}`;
195
+ }
196
+
197
+ function getChildRadius(child: MCTSMoveStatistic): number {
198
+ // Size based on visit count
199
+ const maxN = Math.max(...visibleChildren.value.map((c: MCTSMoveStatistic) => c.N));
200
+ const minRadius = 15;
201
+ const maxRadius = 35;
202
+ return minRadius + ((child.N / maxN) * (maxRadius - minRadius));
203
+ }
204
+
205
+ function getChildColor(child: MCTSMoveStatistic): string {
206
+ const maxN = Math.max(...visibleChildren.value.map((c: MCTSMoveStatistic) => c.N));
207
+ return getColorForStatistic(child.N, "N", 0, maxN, mctsStore.colorScale);
208
+ }
209
+
210
+ function getStrokeWidth(child: MCTSMoveStatistic): number {
211
+ // Thicker lines for more visited nodes
212
+ const maxN = Math.max(...visibleChildren.value.map((c: MCTSMoveStatistic) => c.N));
213
+ return 1 + (child.N / maxN) * 4;
214
+ }
215
+
216
+ function isSelected(actionKey: string): boolean {
217
+ return mctsStore.selectedActionKey === actionKey;
218
+ }
219
+
220
+ function onChildHover(child: MCTSMoveStatistic) {
221
+ hoveredChild.value = child;
222
+ }
223
+
224
+ function onChildLeave() {
225
+ hoveredChild.value = null;
226
+ }
227
+
228
+ function onChildClick(child: MCTSMoveStatistic) {
229
+ mctsStore.selectAction(child.actionKey);
230
+ }
231
+
232
+ function onTopNChange(event: Event) {
233
+ const target = event.target as HTMLInputElement;
234
+ topN.value = parseInt(target.value, 10);
235
+ mctsStore.setTopNFilter(topN.value);
236
+ }
237
+
238
+ // Track mouse for tooltip
239
+ function onMouseMove(event: MouseEvent) {
240
+ tooltipX.value = event.clientX + 10;
241
+ tooltipY.value = event.clientY + 10;
242
+ }
243
+
244
+ if (typeof window !== "undefined") {
245
+ window.addEventListener("mousemove", onMouseMove);
246
+ }
247
+ </script>
248
+
249
+ <style scoped lang="scss">
250
+ .tree-visualization {
251
+ width: 100%;
252
+ display: flex;
253
+ flex-direction: column;
254
+ align-items: center;
255
+ justify-content: flex-start;
256
+ }
257
+
258
+ .no-data {
259
+ display: flex;
260
+ flex-direction: column;
261
+ justify-content: center;
262
+ align-items: center;
263
+ height: 100%;
264
+ gap: 10px;
265
+
266
+ p {
267
+ color: #606060;
268
+ font-size: 14px;
269
+ margin: 0;
270
+ }
271
+
272
+ .note {
273
+ font-size: 11px;
274
+ color: #808080;
275
+ font-style: italic;
276
+ }
277
+ }
278
+
279
+ .tree-content {
280
+ display: flex;
281
+ flex-direction: column;
282
+ gap: 15px;
283
+ width: 100%;
284
+ align-items: center;
285
+ justify-content: flex-start;
286
+ }
287
+
288
+ .tree-svg {
289
+ display: block;
290
+ max-width: 100%;
291
+ height: auto;
292
+ }
293
+
294
+ .node-circle {
295
+ cursor: pointer;
296
+ transition: all 0.2s;
297
+ }
298
+
299
+ .child-node {
300
+ cursor: pointer;
301
+
302
+ &:hover .node-circle {
303
+ filter: brightness(1.2);
304
+ }
305
+
306
+ &.selected .node-circle {
307
+ filter: brightness(1.3);
308
+ }
309
+ }
310
+
311
+ .node-label {
312
+ fill: #e0e0e0;
313
+ font-size: 11px;
314
+ font-weight: 600;
315
+ pointer-events: none;
316
+ }
317
+
318
+ .node-value {
319
+ fill: #e0e0e0;
320
+ font-size: 10px;
321
+ pointer-events: none;
322
+ }
323
+
324
+ .node-percent {
325
+ fill: #a0a0a0;
326
+ font-size: 9px;
327
+ pointer-events: none;
328
+ }
329
+
330
+ .tree-link {
331
+ pointer-events: none;
332
+ }
333
+
334
+ /* Tree Settings */
335
+ .tree-settings {
336
+ padding: 10px 15px;
337
+ background-color: #1a1a1a;
338
+ border-radius: 4px;
339
+ display: flex;
340
+ justify-content: center;
341
+ }
342
+
343
+ .setting-label {
344
+ display: flex;
345
+ align-items: center;
346
+ gap: 10px;
347
+ font-size: 12px;
348
+ color: #c0c0c0;
349
+ }
350
+
351
+ .setting-slider {
352
+ width: 150px;
353
+ height: 4px;
354
+ border-radius: 2px;
355
+ background-color: #3a3a3a;
356
+ outline: none;
357
+ -webkit-appearance: none;
358
+
359
+ &::-webkit-slider-thumb {
360
+ -webkit-appearance: none;
361
+ width: 12px;
362
+ height: 12px;
363
+ border-radius: 50%;
364
+ background-color: #4a90e2;
365
+ cursor: pointer;
366
+ }
367
+
368
+ &::-moz-range-thumb {
369
+ width: 12px;
370
+ height: 12px;
371
+ border-radius: 50%;
372
+ background-color: #4a90e2;
373
+ cursor: pointer;
374
+ border: none;
375
+ }
376
+ }
377
+
378
+ .setting-value {
379
+ font-weight: 500;
380
+ color: #e0e0e0;
381
+ }
382
+
383
+ /* Tooltip */
384
+ .tooltip {
385
+ position: fixed;
386
+ background-color: rgba(0, 0, 0, 0.9);
387
+ border: 1px solid #4a90e2;
388
+ border-radius: 4px;
389
+ padding: 8px 12px;
390
+ font-size: 11px;
391
+ color: #e0e0e0;
392
+ pointer-events: none;
393
+ z-index: 1000;
394
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
395
+ }
396
+
397
+ .tooltip-pos {
398
+ font-weight: 600;
399
+ color: #4a90e2;
400
+ margin-bottom: 4px;
401
+ }
402
+
403
+ .tooltip-line {
404
+ color: #c0c0c0;
405
+ line-height: 1.4;
406
+ }
407
+ </style>
trigo-web/app/src/composables/useRoomHash.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Composable for managing room ID in URL hash
3
+ * Enables shareable room links for VS People multiplayer mode
4
+ */
5
+
6
+ export function useRoomHash() {
7
+ /**
8
+ * Extract room ID from URL hash
9
+ * @returns Room ID string or null if no hash present
10
+ */
11
+ function getRoomIdFromHash(): string | null {
12
+ const hash = window.location.hash.substring(1); // Remove '#' prefix
13
+ return hash || null;
14
+ }
15
+
16
+
17
+ /**
18
+ * Update URL hash with room ID
19
+ * Uses replaceState to avoid adding browser history entry
20
+ * @param roomId - 8-character uppercase alphanumeric room ID
21
+ */
22
+ function updateHash(roomId: string): void {
23
+ const newUrl = `${window.location.pathname}#${roomId}`;
24
+ window.history.replaceState(null, "", newUrl);
25
+ }
26
+
27
+
28
+ /**
29
+ * Remove hash from URL
30
+ * Clears room ID from address bar
31
+ */
32
+ function clearHash(): void {
33
+ const newUrl = window.location.pathname;
34
+ window.history.replaceState(null, "", newUrl);
35
+ }
36
+
37
+
38
+ /**
39
+ * Validate room ID format
40
+ * Must match backend generation: 8 uppercase alphanumeric characters
41
+ * @param roomId - Room ID to validate
42
+ * @returns true if valid format, false otherwise
43
+ */
44
+ function isValidRoomId(roomId: string): boolean {
45
+ return /^[A-Z0-9]{8}$/.test(roomId);
46
+ }
47
+
48
+
49
+ return {
50
+ getRoomIdFromHash,
51
+ updateHash,
52
+ clearHash,
53
+ isValidRoomId
54
+ };
55
+ }
trigo-web/app/src/composables/useSocket.ts CHANGED
@@ -4,9 +4,11 @@ import { io, Socket } from "socket.io-client";
4
  // Singleton socket instance
5
  let socketInstance: Socket | null = null;
6
 
 
 
 
 
7
  export function useSocket() {
8
- const connected = ref(false);
9
- const error = ref<string | null>(null);
10
 
11
  // Get or create socket instance
12
  const getSocket = (): Socket => {
@@ -16,10 +18,8 @@ export function useSocket() {
16
 
17
  let serverUrl: string;
18
  if (isDev) {
19
- // Development: Use same host as frontend, port 3000
20
- // This allows accessing from local IP (e.g., 192.168.x.x:5173)
21
- const currentHost = window.location.hostname;
22
- serverUrl = `http://${currentHost}:3000`;
23
  } else {
24
  // Production: same origin
25
  serverUrl = window.location.origin;
@@ -50,6 +50,9 @@ export function useSocket() {
50
  error.value = err.message;
51
  console.error("[Socket.io] Connection error:", err.message);
52
  });
 
 
 
53
  }
54
 
55
  return socketInstance;
@@ -85,6 +88,171 @@ export function useSocket() {
85
  });
86
  };
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  // Clean up on unmount
89
  onUnmounted(() => {
90
  // Note: We don't disconnect the singleton instance
@@ -95,7 +263,38 @@ export function useSocket() {
95
  socket: getSocket(),
96
  connected,
97
  error,
98
- sendEcho
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  };
100
  }
101
 
 
4
  // Singleton socket instance
5
  let socketInstance: Socket | null = null;
6
 
7
+ // Singleton reactive refs (shared across all useSocket() calls)
8
+ const connected = ref(false);
9
+ const error = ref<string | null>(null);
10
+
11
  export function useSocket() {
 
 
12
 
13
  // Get or create socket instance
14
  const getSocket = (): Socket => {
 
18
 
19
  let serverUrl: string;
20
  if (isDev) {
21
+ // Development: Use VITE_SERVER_URL from .env or .env.local
22
+ serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3000";
 
 
23
  } else {
24
  // Production: same origin
25
  serverUrl = window.location.origin;
 
50
  error.value = err.message;
51
  console.error("[Socket.io] Connection error:", err.message);
52
  });
53
+ } else {
54
+ // Socket already exists, sync the connected state
55
+ connected.value = socketInstance.connected;
56
  }
57
 
58
  return socketInstance;
 
88
  });
89
  };
90
 
91
+ // Join a room
92
+ const joinRoom = (
93
+ data: { roomId?: string; nickname: string },
94
+ callback: (response: any) => void
95
+ ): void => {
96
+ const socket = getSocket();
97
+ console.log("[useSocket] joinRoom called:", {
98
+ roomId: data.roomId,
99
+ nickname: data.nickname,
100
+ socketConnected: socket.connected,
101
+ socketId: socket.id
102
+ });
103
+ socket.emit("joinRoom", data, callback);
104
+ console.log("[useSocket] joinRoom event emitted");
105
+ };
106
+
107
+ // Leave current room
108
+ const leaveRoom = (): void => {
109
+ const socket = getSocket();
110
+ socket.emit("leaveRoom");
111
+ };
112
+
113
+ // Change player nickname
114
+ const changeNickname = (nickname: string, callback?: (response: any) => void): void => {
115
+ const socket = getSocket();
116
+ socket.emit("changeNickname", { nickname }, callback);
117
+ };
118
+
119
+
120
+ // Game actions
121
+
122
+ // Make a move (place stone)
123
+ const makeMove = (x: number, y: number, z: number): void => {
124
+ const socket = getSocket();
125
+ socket.emit("makeMove", { x, y, z });
126
+ };
127
+
128
+ // Pass turn
129
+ const pass = (): void => {
130
+ const socket = getSocket();
131
+ socket.emit("pass");
132
+ };
133
+
134
+ // Resign game
135
+ const resign = (): void => {
136
+ const socket = getSocket();
137
+ socket.emit("resign");
138
+ };
139
+
140
+ // Undo move
141
+ const undoMove = (callback?: (response: any) => void): void => {
142
+ const socket = getSocket();
143
+ socket.emit("undoMove", callback);
144
+ };
145
+
146
+ // Redo move
147
+ const redoMove = (callback?: (response: any) => void): void => {
148
+ const socket = getSocket();
149
+ socket.emit("redoMove", callback);
150
+ };
151
+
152
+ // Reset game
153
+ const resetGame = (
154
+ options?: { boardShape?: { x: number; y: number; z: number }; playerColors?: { [playerId: string]: "black" | "white" } },
155
+ callback?: (response: any) => void
156
+ ): void => {
157
+ const socket = getSocket();
158
+ socket.emit("resetGame", options, callback);
159
+ };
160
+
161
+
162
+ // Event listeners
163
+ const onPlayerJoined = (handler: (data: { playerId: string; nickname: string }) => void): void => {
164
+ const socket = getSocket();
165
+ socket.on("playerJoined", handler);
166
+ };
167
+
168
+ const onPlayerLeft = (handler: (data: { playerId: string }) => void): void => {
169
+ const socket = getSocket();
170
+ socket.on("playerLeft", handler);
171
+ };
172
+
173
+ const onNicknameChanged = (
174
+ handler: (data: { playerId: string; nickname: string; oldNickname: string }) => void
175
+ ): void => {
176
+ const socket = getSocket();
177
+ socket.on("nicknameChanged", handler);
178
+ };
179
+
180
+ const onRoomJoined = (handler: (data: any) => void): void => {
181
+ const socket = getSocket();
182
+ socket.on("roomJoined", handler);
183
+ };
184
+
185
+ const onGameUpdate = (handler: (data: any) => void): void => {
186
+ const socket = getSocket();
187
+ socket.on("gameUpdate", handler);
188
+ };
189
+
190
+ const onGameEnded = (handler: (data: any) => void): void => {
191
+ const socket = getSocket();
192
+ socket.on("gameEnded", handler);
193
+ };
194
+
195
+ const onGameReset = (handler: (data: any) => void): void => {
196
+ const socket = getSocket();
197
+ socket.on("gameReset", handler);
198
+ };
199
+
200
+ const onPlayerDisconnected = (handler: (data: { playerId: string }) => void): void => {
201
+ const socket = getSocket();
202
+ socket.on("playerDisconnected", handler);
203
+ };
204
+
205
+ const onError = (handler: (data: { message: string }) => void): void => {
206
+ const socket = getSocket();
207
+ socket.on("error", handler);
208
+ };
209
+
210
+ // Remove event listeners
211
+ const offPlayerJoined = (handler?: any): void => {
212
+ const socket = getSocket();
213
+ socket.off("playerJoined", handler);
214
+ };
215
+
216
+ const offPlayerLeft = (handler?: any): void => {
217
+ const socket = getSocket();
218
+ socket.off("playerLeft", handler);
219
+ };
220
+
221
+ const offNicknameChanged = (handler?: any): void => {
222
+ const socket = getSocket();
223
+ socket.off("nicknameChanged", handler);
224
+ };
225
+
226
+ const offRoomJoined = (handler?: any): void => {
227
+ const socket = getSocket();
228
+ socket.off("roomJoined", handler);
229
+ };
230
+
231
+ const offGameUpdate = (handler?: any): void => {
232
+ const socket = getSocket();
233
+ socket.off("gameUpdate", handler);
234
+ };
235
+
236
+ const offGameEnded = (handler?: any): void => {
237
+ const socket = getSocket();
238
+ socket.off("gameEnded", handler);
239
+ };
240
+
241
+ const offGameReset = (handler?: any): void => {
242
+ const socket = getSocket();
243
+ socket.off("gameReset", handler);
244
+ };
245
+
246
+ const offPlayerDisconnected = (handler?: any): void => {
247
+ const socket = getSocket();
248
+ socket.off("playerDisconnected", handler);
249
+ };
250
+
251
+ const offError = (handler?: any): void => {
252
+ const socket = getSocket();
253
+ socket.off("error", handler);
254
+ };
255
+
256
  // Clean up on unmount
257
  onUnmounted(() => {
258
  // Note: We don't disconnect the singleton instance
 
263
  socket: getSocket(),
264
  connected,
265
  error,
266
+ sendEcho,
267
+ // Room management
268
+ joinRoom,
269
+ leaveRoom,
270
+ changeNickname,
271
+ // Game actions
272
+ makeMove,
273
+ pass,
274
+ resign,
275
+ undoMove,
276
+ redoMove,
277
+ resetGame,
278
+ // Event listeners
279
+ onPlayerJoined,
280
+ onPlayerLeft,
281
+ onNicknameChanged,
282
+ onRoomJoined,
283
+ onGameUpdate,
284
+ onGameEnded,
285
+ onGameReset,
286
+ onPlayerDisconnected,
287
+ onError,
288
+ // Event cleanup
289
+ offPlayerJoined,
290
+ offPlayerLeft,
291
+ offNicknameChanged,
292
+ offRoomJoined,
293
+ offGameUpdate,
294
+ offGameEnded,
295
+ offGameReset,
296
+ offPlayerDisconnected,
297
+ offError
298
  };
299
  }
300
 
trigo-web/app/src/composables/useTrigoAgent.ts CHANGED
@@ -14,7 +14,7 @@ import { ref, onUnmounted } from "vue";
14
  import { TrigoTreeAgent } from "@inc/trigoTreeAgent";
15
  import { OnnxInferencer } from "@/services/onnxInferencer";
16
  import type { TrigoGame } from "@inc/trigo/game";
17
- import type { Position } from "@inc/trigo/types";
18
 
19
  /**
20
  * Composable for using the Trigo AI agent in Vue components
@@ -49,9 +49,9 @@ export function useTrigoAgent() {
49
 
50
  // Create OnnxInferencer with evaluation model
51
  inferencer = new OnnxInferencer({
52
- modelPath: "/onnx/GPT2CausalLM_ep0015_evaluation.onnx",
53
- vocabSize: 259,
54
- seqLen: 2048 // Evaluation models support longer sequences
55
  });
56
 
57
  // Initialize ONNX model
@@ -75,10 +75,10 @@ export function useTrigoAgent() {
75
  * Generate the next move for the AI player
76
  *
77
  * @param game - Current game instance
78
- * @returns The selected move position, or null if no valid moves or pass move
79
  * @throws Error if agent is not initialized
80
  */
81
- const generateMove = async (game: TrigoGame): Promise<Position | null> => {
82
  if (!agent) {
83
  throw new Error("AI agent not initialized. Call initialize() first.");
84
  }
@@ -106,14 +106,19 @@ export function useTrigoAgent() {
106
  lastMoveTime.value = elapsed;
107
  console.log(`[useTrigoAgent] ✓ Move generated in ${elapsed.toFixed(2)}ms`);
108
 
109
- // Convert Move to Position (return null if pass move)
110
- if (!move || move.isPass) {
111
- console.log("[useTrigoAgent] AI chose to pass");
112
  return null;
113
  }
114
 
 
 
 
 
 
115
  if (move.x !== undefined && move.y !== undefined && move.z !== undefined) {
116
- return { x: move.x, y: move.y, z: move.z };
117
  }
118
 
119
  console.warn("[useTrigoAgent] Move has undefined coordinates:", move);
 
14
  import { TrigoTreeAgent } from "@inc/trigoTreeAgent";
15
  import { OnnxInferencer } from "@/services/onnxInferencer";
16
  import type { TrigoGame } from "@inc/trigo/game";
17
+ import type { Move } from "@inc/trigo/types";
18
 
19
  /**
20
  * Composable for using the Trigo AI agent in Vue components
 
49
 
50
  // Create OnnxInferencer with evaluation model
51
  inferencer = new OnnxInferencer({
52
+ modelPath: import.meta.env.VITE_ONNX_TREE_MODEL,
53
+ vocabSize: 128,
54
+ seqLen: 256
55
  });
56
 
57
  // Initialize ONNX model
 
75
  * Generate the next move for the AI player
76
  *
77
  * @param game - Current game instance
78
+ * @returns The selected move (can be a pass move with isPass: true), or null if no valid moves
79
  * @throws Error if agent is not initialized
80
  */
81
+ const generateMove = async (game: TrigoGame): Promise<Move | null> => {
82
  if (!agent) {
83
  throw new Error("AI agent not initialized. Call initialize() first.");
84
  }
 
106
  lastMoveTime.value = elapsed;
107
  console.log(`[useTrigoAgent] ✓ Move generated in ${elapsed.toFixed(2)}ms`);
108
 
109
+ // Return the Move object directly (can be pass move or normal move)
110
+ if (!move) {
111
+ console.log("[useTrigoAgent] No valid move available");
112
  return null;
113
  }
114
 
115
+ if (move.isPass) {
116
+ console.log("[useTrigoAgent] AI chose to pass");
117
+ return move; // Return the pass move object
118
+ }
119
+
120
  if (move.x !== undefined && move.y !== undefined && move.z !== undefined) {
121
+ return move; // Return the normal move object
122
  }
123
 
124
  console.warn("[useTrigoAgent] Move has undefined coordinates:", move);
trigo-web/app/src/data/defaultNicknames.ts ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Default Nicknames for Trigo Players
3
+ *
4
+ * A curated list of common first names used for random nickname generation.
5
+ * When a user first visits the VS People mode, they're assigned a random
6
+ * nickname from this list. The nickname persists in localStorage and can
7
+ * be changed at any time.
8
+ */
9
+
10
+ export const DEFAULT_NICKNAMES: string[] = [
11
+ // Male names (50)
12
+ "John",
13
+ "James",
14
+ "Michael",
15
+ "David",
16
+ "William",
17
+ "Richard",
18
+ "Joseph",
19
+ "Thomas",
20
+ "Charles",
21
+ "Daniel",
22
+ "Matthew",
23
+ "Anthony",
24
+ "Mark",
25
+ "Donald",
26
+ "Steven",
27
+ "Paul",
28
+ "Andrew",
29
+ "Joshua",
30
+ "Kenneth",
31
+ "Kevin",
32
+ "Brian",
33
+ "George",
34
+ "Edward",
35
+ "Ronald",
36
+ "Timothy",
37
+ "Jason",
38
+ "Jeffrey",
39
+ "Ryan",
40
+ "Jacob",
41
+ "Gary",
42
+ "Nicholas",
43
+ "Eric",
44
+ "Jonathan",
45
+ "Stephen",
46
+ "Larry",
47
+ "Justin",
48
+ "Scott",
49
+ "Brandon",
50
+ "Benjamin",
51
+ "Samuel",
52
+ "Raymond",
53
+ "Gregory",
54
+ "Frank",
55
+ "Alexander",
56
+ "Patrick",
57
+ "Jack",
58
+ "Dennis",
59
+ "Jerry",
60
+ "Tyler",
61
+ "Aaron",
62
+
63
+ // Female names (50)
64
+ "Mary",
65
+ "Patricia",
66
+ "Jennifer",
67
+ "Linda",
68
+ "Barbara",
69
+ "Elizabeth",
70
+ "Susan",
71
+ "Jessica",
72
+ "Sarah",
73
+ "Karen",
74
+ "Nancy",
75
+ "Lisa",
76
+ "Betty",
77
+ "Margaret",
78
+ "Sandra",
79
+ "Ashley",
80
+ "Kimberly",
81
+ "Emily",
82
+ "Donna",
83
+ "Michelle",
84
+ "Dorothy",
85
+ "Carol",
86
+ "Amanda",
87
+ "Melissa",
88
+ "Deborah",
89
+ "Stephanie",
90
+ "Rebecca",
91
+ "Sharon",
92
+ "Laura",
93
+ "Cynthia",
94
+ "Kathleen",
95
+ "Amy",
96
+ "Angela",
97
+ "Shirley",
98
+ "Anna",
99
+ "Brenda",
100
+ "Pamela",
101
+ "Emma",
102
+ "Nicole",
103
+ "Helen",
104
+ "Samantha",
105
+ "Katherine",
106
+ "Christine",
107
+ "Debra",
108
+ "Rachel",
109
+ "Catherine",
110
+ "Carolyn",
111
+ "Janet",
112
+ "Ruth",
113
+ "Maria",
114
+
115
+ // Neutral/Additional (15)
116
+ "Alex",
117
+ "Jordan",
118
+ "Taylor",
119
+ "Morgan",
120
+ "Casey",
121
+ "Riley",
122
+ "Jamie",
123
+ "Avery",
124
+ "Quinn",
125
+ "Sage",
126
+ "Robin",
127
+ "Charlie",
128
+ "Blake",
129
+ "Cameron",
130
+ "Hayden"
131
+ ];
132
+
133
+ /**
134
+ * Get a random nickname from the default list
135
+ * @returns A randomly selected name from DEFAULT_NICKNAMES
136
+ */
137
+ export function getRandomNickname(): string {
138
+ const index = Math.floor(Math.random() * DEFAULT_NICKNAMES.length);
139
+ return DEFAULT_NICKNAMES[index];
140
+ }
trigo-web/app/src/router/index.ts CHANGED
@@ -3,6 +3,8 @@ import TrigoView from "@/views/TrigoView.vue";
3
  import OnnxTestView from "@/views/OnnxTestView.vue";
4
  import TrigoAgentTestView from "@/views/TrigoAgentTestView.vue";
5
  import TrigoTreeTestView from "@/views/TrigoTreeTestView.vue";
 
 
6
 
7
  const router = createRouter({
8
  history: createWebHistory(import.meta.env.BASE_URL),
@@ -45,6 +47,16 @@ const router = createRouter({
45
  path: "/tree-test",
46
  name: "tree-test",
47
  component: TrigoTreeTestView
 
 
 
 
 
 
 
 
 
 
48
  }
49
  ]
50
  });
 
3
  import OnnxTestView from "@/views/OnnxTestView.vue";
4
  import TrigoAgentTestView from "@/views/TrigoAgentTestView.vue";
5
  import TrigoTreeTestView from "@/views/TrigoTreeTestView.vue";
6
+ import SocketTestView from "@/views/SocketTestView.vue";
7
+ import MCTSAnalysisView from "@/views/MCTSAnalysisView.vue";
8
 
9
  const router = createRouter({
10
  history: createWebHistory(import.meta.env.BASE_URL),
 
47
  path: "/tree-test",
48
  name: "tree-test",
49
  component: TrigoTreeTestView
50
+ },
51
+ {
52
+ path: "/socket-test",
53
+ name: "socket-test",
54
+ component: SocketTestView
55
+ },
56
+ {
57
+ path: "/mcts-analysis",
58
+ name: "mcts-analysis",
59
+ component: MCTSAnalysisView
60
  }
61
  ]
62
  });
trigo-web/app/src/stores/gameStore.ts CHANGED
@@ -297,6 +297,76 @@ export const useGameStore = defineStore("game", {
297
  } catch (error) {
298
  console.error("Failed to clear session storage:", error);
299
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  }
301
  }
302
  });
 
297
  } catch (error) {
298
  console.error("Failed to clear session storage:", error);
299
  }
300
+ },
301
+
302
+
303
+ /**
304
+ * Set current player (for multiplayer sync)
305
+ */
306
+ setCurrentPlayer(player: Player): void {
307
+ // This is handled by the underlying game state
308
+ // The current player is determined by the move history
309
+ // For proper sync, use loadFromTGN instead
310
+ console.log(`[gameStore] setCurrentPlayer called with: ${player}`);
311
+ },
312
+
313
+
314
+ /**
315
+ * Load game state from TGN string (for multiplayer sync)
316
+ */
317
+ loadFromTGN(tgn: string): boolean {
318
+ try {
319
+ // Create a new game from TGN
320
+ const { TrigoGame } = require("../../../inc/trigo/game");
321
+ const newGame = TrigoGame.fromTGN(tgn);
322
+
323
+ // Create new frontend game with same shape
324
+ const shape = newGame.getShape();
325
+ this.game = new TrigoGameFrontend(shape);
326
+
327
+ // Replay all moves to sync state
328
+ const history = newGame.getStepHistory();
329
+ this.game.startGame();
330
+
331
+ for (const step of history) {
332
+ if (step.type === "drop" && step.position) {
333
+ this.game.makeMove(step.position.x, step.position.y, step.position.z);
334
+ } else if (step.type === "pass") {
335
+ this.game.pass();
336
+ }
337
+ }
338
+
339
+ this.saveToSessionStorage();
340
+ return true;
341
+ } catch (error) {
342
+ console.error("[gameStore] Failed to load from TGN:", error);
343
+ return false;
344
+ }
345
+ },
346
+
347
+
348
+ /**
349
+ * Get all stones on the board (for viewport sync)
350
+ */
351
+ getAllStones(): Array<{ x: number; y: number; z: number; color: Player }> {
352
+ const stones: Array<{ x: number; y: number; z: number; color: Player }> = [];
353
+ const board = this.game.getBoard();
354
+ const shape = this.game.getShape();
355
+
356
+ for (let x = 0; x < shape.x; x++) {
357
+ for (let y = 0; y < shape.y; y++) {
358
+ for (let z = 0; z < shape.z; z++) {
359
+ const stone = board[x][y][z];
360
+ if (stone === 1) {
361
+ stones.push({ x, y, z, color: "black" });
362
+ } else if (stone === 2) {
363
+ stones.push({ x, y, z, color: "white" });
364
+ }
365
+ }
366
+ }
367
+ }
368
+
369
+ return stones;
370
  }
371
  }
372
  });
trigo-web/app/src/stores/mctsStore.ts ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pinia store for MCTS visualization state
3
+ */
4
+
5
+ import { defineStore } from "pinia";
6
+ import { ref, computed } from "vue";
7
+ import type {
8
+ MCTSMoveData,
9
+ MCTSMoveStatistic,
10
+ MCTSStatisticType,
11
+ ColorScaleType
12
+ } from "@/types/mcts";
13
+ import type { Position } from "../../../inc/trigo/types";
14
+
15
+
16
+ export const useMCTSStore = defineStore("mcts", () => {
17
+ // ============================================================================
18
+ // State
19
+ // ============================================================================
20
+
21
+ // Game data
22
+ const gameHistory = ref<MCTSMoveData[]>([]);
23
+ const currentMoveIndex = ref<number>(0);
24
+
25
+ // Visualization settings
26
+ const selectedStatistic = ref<MCTSStatisticType>("N");
27
+ const colorScale = ref<ColorScaleType>("linear");
28
+ const topNFilter = ref<number>(10); // Show top N moves in tree/table
29
+
30
+ // Selection state
31
+ const selectedActionKey = ref<string | null>(null);
32
+
33
+
34
+ // ============================================================================
35
+ // Getters
36
+ // ============================================================================
37
+
38
+ const hasData = computed(() => gameHistory.value.length > 0);
39
+
40
+ const currentMoveData = computed((): MCTSMoveData | null => {
41
+ if (!hasData.value) return null;
42
+ return gameHistory.value[currentMoveIndex.value] || null;
43
+ });
44
+
45
+ const maxMoveIndex = computed(() => {
46
+ return Math.max(0, gameHistory.value.length - 1);
47
+ });
48
+
49
+ const currentStatistics = computed((): MCTSMoveStatistic[] => {
50
+ return currentMoveData.value?.statistics || [];
51
+ });
52
+
53
+ const topMoves = computed((): MCTSMoveStatistic[] => {
54
+ const stats = currentStatistics.value;
55
+ return stats
56
+ .slice()
57
+ .sort((a, b) => b.N - a.N)
58
+ .slice(0, topNFilter.value);
59
+ });
60
+
61
+ const totalVisits = computed((): number => {
62
+ return currentStatistics.value.reduce((sum, stat) => sum + stat.N, 0);
63
+ });
64
+
65
+ const selectedMove = computed((): MCTSMoveStatistic | null => {
66
+ if (!selectedActionKey.value) return null;
67
+ return (
68
+ currentStatistics.value.find(
69
+ (stat) => stat.actionKey === selectedActionKey.value
70
+ ) || null
71
+ );
72
+ });
73
+
74
+
75
+ // ============================================================================
76
+ // Actions
77
+ // ============================================================================
78
+
79
+ function setGameHistory(history: MCTSMoveData[]) {
80
+ gameHistory.value = history;
81
+ currentMoveIndex.value = 0;
82
+ selectedActionKey.value = null;
83
+ }
84
+
85
+ function clearGameHistory() {
86
+ gameHistory.value = [];
87
+ currentMoveIndex.value = 0;
88
+ selectedActionKey.value = null;
89
+ }
90
+
91
+ function setCurrentMoveIndex(index: number) {
92
+ if (index < 0 || index > maxMoveIndex.value) return;
93
+ currentMoveIndex.value = index;
94
+ // Clear selection when moving to different move
95
+ selectedActionKey.value = null;
96
+ }
97
+
98
+ function nextMove() {
99
+ if (currentMoveIndex.value < maxMoveIndex.value) {
100
+ setCurrentMoveIndex(currentMoveIndex.value + 1);
101
+ }
102
+ }
103
+
104
+ function prevMove() {
105
+ if (currentMoveIndex.value > 0) {
106
+ setCurrentMoveIndex(currentMoveIndex.value - 1);
107
+ }
108
+ }
109
+
110
+ function firstMove() {
111
+ setCurrentMoveIndex(0);
112
+ }
113
+
114
+ function lastMove() {
115
+ setCurrentMoveIndex(maxMoveIndex.value);
116
+ }
117
+
118
+ function setSelectedStatistic(stat: MCTSStatisticType) {
119
+ selectedStatistic.value = stat;
120
+ }
121
+
122
+ function setColorScale(scale: ColorScaleType) {
123
+ colorScale.value = scale;
124
+ }
125
+
126
+ function setTopNFilter(n: number) {
127
+ topNFilter.value = Math.max(3, Math.min(50, n));
128
+ }
129
+
130
+ function selectAction(actionKey: string | null) {
131
+ selectedActionKey.value = actionKey;
132
+ }
133
+
134
+ function selectPosition(position: Position | null) {
135
+ if (!position) {
136
+ selectedActionKey.value = "pass";
137
+ return;
138
+ }
139
+ selectedActionKey.value = `${position.x},${position.y},${position.z}`;
140
+ }
141
+
142
+
143
+ // ============================================================================
144
+ // Return
145
+ // ============================================================================
146
+
147
+ return {
148
+ // State
149
+ gameHistory,
150
+ currentMoveIndex,
151
+ selectedStatistic,
152
+ colorScale,
153
+ topNFilter,
154
+ selectedActionKey,
155
+
156
+ // Getters
157
+ hasData,
158
+ currentMoveData,
159
+ maxMoveIndex,
160
+ currentStatistics,
161
+ topMoves,
162
+ totalVisits,
163
+ selectedMove,
164
+
165
+ // Actions
166
+ setGameHistory,
167
+ clearGameHistory,
168
+ setCurrentMoveIndex,
169
+ nextMove,
170
+ prevMove,
171
+ firstMove,
172
+ lastMove,
173
+ setSelectedStatistic,
174
+ setColorScale,
175
+ setTopNFilter,
176
+ selectAction,
177
+ selectPosition
178
+ };
179
+ });
trigo-web/app/src/stores/playerStore.ts ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Player Store
3
+ *
4
+ * Manages player state for multiplayer mode, including nickname, connection status,
5
+ * and opponent information.
6
+ */
7
+
8
+ import { defineStore } from "pinia";
9
+ import { StorageKey, localStorageManager } from "@/utils/storage";
10
+ import { getRandomNickname } from "@/data/defaultNicknames";
11
+
12
+
13
+ /**
14
+ * Player state interface
15
+ */
16
+ export interface PlayerState {
17
+ nickname: string; // Local player nickname
18
+ playerId: string | null; // Socket ID
19
+ playerColor: "black" | "white" | null; // Player's stone color in current game
20
+ roomId: string | null; // Current room ID
21
+ opponentNickname: string | null; // Opponent's nickname
22
+ connectionStatus: "disconnected" | "connected" | "in-room"; // Connection state
23
+ }
24
+
25
+
26
+ /**
27
+ * Nickname validation result
28
+ */
29
+ interface ValidationResult {
30
+ valid: boolean;
31
+ error?: string;
32
+ }
33
+
34
+
35
+ /**
36
+ * Validate a nickname against the rules:
37
+ * - Length: 3-20 characters
38
+ * - Characters: alphanumeric + spaces only
39
+ * - No leading/trailing whitespace
40
+ */
41
+ function validateNickname(nickname: string): ValidationResult {
42
+ if (!nickname || typeof nickname !== "string") {
43
+ return { valid: false, error: "Nickname is required" };
44
+ }
45
+
46
+ if (nickname.length < 3) {
47
+ return { valid: false, error: "Nickname must be at least 3 characters" };
48
+ }
49
+
50
+ if (nickname.length > 20) {
51
+ return { valid: false, error: "Nickname must be 20 characters or less" };
52
+ }
53
+
54
+ if (!/^[a-zA-Z0-9 ]+$/.test(nickname)) {
55
+ return { valid: false, error: "Only letters, numbers, and spaces allowed" };
56
+ }
57
+
58
+ if (nickname.trim() !== nickname) {
59
+ return { valid: false, error: "No leading or trailing spaces allowed" };
60
+ }
61
+
62
+ return { valid: true };
63
+ }
64
+
65
+
66
+ /**
67
+ * Load nickname from localStorage or generate a random one
68
+ */
69
+ function loadNickname(): string {
70
+ const stored = localStorageManager.getString(StorageKey.PLAYER_NICKNAME);
71
+ if (stored) {
72
+ // Validate stored nickname
73
+ const validation = validateNickname(stored);
74
+ if (validation.valid) {
75
+ return stored;
76
+ }
77
+ }
78
+ // Generate random nickname if none exists or invalid
79
+ return getRandomNickname();
80
+ }
81
+
82
+
83
+ /**
84
+ * Save nickname to localStorage
85
+ */
86
+ function saveNickname(nickname: string): void {
87
+ localStorageManager.setString(StorageKey.PLAYER_NICKNAME, nickname);
88
+ }
89
+
90
+
91
+ /**
92
+ * Player store
93
+ */
94
+ export const usePlayerStore = defineStore("player", {
95
+ state: (): PlayerState => ({
96
+ nickname: loadNickname(),
97
+ playerId: null,
98
+ playerColor: null,
99
+ roomId: null,
100
+ opponentNickname: null,
101
+ connectionStatus: "disconnected"
102
+ }),
103
+
104
+ getters: {
105
+ /**
106
+ * Is player currently in a room?
107
+ */
108
+ isInRoom: (state): boolean => state.connectionStatus === "in-room",
109
+
110
+ /**
111
+ * Does player have an opponent in the room?
112
+ */
113
+ hasOpponent: (state): boolean => state.opponentNickname !== null,
114
+
115
+ /**
116
+ * Display name with fallback to "Guest"
117
+ */
118
+ displayName: (state): string => state.nickname || "Guest",
119
+
120
+ /**
121
+ * Is player connected to socket server?
122
+ */
123
+ isConnected: (state): boolean => state.connectionStatus !== "disconnected"
124
+ },
125
+
126
+ actions: {
127
+ /**
128
+ * Set the player's nickname
129
+ * @param nickname - New nickname to set
130
+ * @returns true if successful, false if validation failed
131
+ */
132
+ setNickname(nickname: string): boolean {
133
+ const validation = validateNickname(nickname);
134
+ if (!validation.valid) {
135
+ console.warn(`[PlayerStore] Invalid nickname: ${validation.error}`);
136
+ return false;
137
+ }
138
+
139
+ this.nickname = nickname;
140
+ saveNickname(nickname);
141
+ return true;
142
+ },
143
+
144
+ /**
145
+ * Set the player's socket ID
146
+ * @param id - Socket ID from server
147
+ */
148
+ setPlayerId(id: string): void {
149
+ this.playerId = id;
150
+ this.connectionStatus = "connected";
151
+ },
152
+
153
+ /**
154
+ * Join a room with the given ID and assigned color
155
+ * @param roomId - Room ID
156
+ * @param color - Assigned player color
157
+ */
158
+ joinRoom(roomId: string, color: "black" | "white"): void {
159
+ this.roomId = roomId;
160
+ this.playerColor = color;
161
+ this.connectionStatus = "in-room";
162
+ },
163
+
164
+ /**
165
+ * Set the opponent's nickname
166
+ * @param nickname - Opponent's nickname
167
+ */
168
+ setOpponentNickname(nickname: string): void {
169
+ this.opponentNickname = nickname;
170
+ },
171
+
172
+ /**
173
+ * Leave the current room
174
+ */
175
+ leaveRoom(): void {
176
+ this.roomId = null;
177
+ this.playerColor = null;
178
+ this.opponentNickname = null;
179
+ this.connectionStatus = "connected";
180
+ },
181
+
182
+ /**
183
+ * Disconnect from the server
184
+ */
185
+ disconnect(): void {
186
+ this.playerId = null;
187
+ this.roomId = null;
188
+ this.playerColor = null;
189
+ this.opponentNickname = null;
190
+ this.connectionStatus = "disconnected";
191
+ },
192
+
193
+ /**
194
+ * Reset player state to initial state
195
+ * Note: Nickname is preserved in localStorage
196
+ */
197
+ reset(): void {
198
+ this.playerId = null;
199
+ this.playerColor = null;
200
+ this.roomId = null;
201
+ this.opponentNickname = null;
202
+ this.connectionStatus = "disconnected";
203
+ }
204
+ }
205
+ });
206
+
207
+
208
+ /**
209
+ * Export validation function for use in other components
210
+ */
211
+ export { validateNickname };
trigo-web/app/src/styles/test-pages.scss ADDED
@@ -0,0 +1,554 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared styles for test/analysis pages
3
+ *
4
+ * Provides consistent styling for:
5
+ * - MCTSAnalysisView
6
+ * - TrigoTreeTestView
7
+ * - TrigoAgentTestView
8
+ * - OnnxTestView
9
+ * - SocketTestView
10
+ */
11
+
12
+
13
+ // ============================================================================
14
+ // Theme Variables
15
+ // ============================================================================
16
+
17
+ // Light theme colors
18
+ $light-bg-primary: #f5f5f5;
19
+ $light-bg-secondary: white;
20
+ $light-bg-tertiary: #f8f9fa;
21
+ $light-text-primary: #333;
22
+ $light-text-secondary: #666;
23
+ $light-border: #e1e4e8;
24
+ $light-shadow: rgba(0, 0, 0, 0.1);
25
+
26
+ // Dark theme colors
27
+ $dark-bg-primary: #1a1a1a;
28
+ $dark-bg-secondary: #2a2a2a;
29
+ $dark-bg-tertiary: #3a3a3a;
30
+ $dark-text-primary: #e0e0e0;
31
+ $dark-text-secondary: #a0a0a0;
32
+ $dark-border: #404040;
33
+ $dark-shadow: rgba(0, 0, 0, 0.3);
34
+
35
+ // Status colors (shared)
36
+ $color-success: #28a745;
37
+ $color-warning: #ffc107;
38
+ $color-error: #dc3545;
39
+ $color-info: #007bff;
40
+ $color-primary: #4a90e2;
41
+ $color-accent: #e94560;
42
+
43
+
44
+ // ============================================================================
45
+ // Custom Scrollbar Mixin
46
+ // ============================================================================
47
+
48
+ @mixin custom-scrollbar($theme: 'dark') {
49
+ @if $theme == 'dark' {
50
+ &::-webkit-scrollbar {
51
+ width: 8px;
52
+ height: 8px;
53
+ }
54
+
55
+ &::-webkit-scrollbar-track {
56
+ background: $dark-bg-primary;
57
+ border-radius: 4px;
58
+ }
59
+
60
+ &::-webkit-scrollbar-thumb {
61
+ background: #4a4a4a;
62
+ border-radius: 4px;
63
+
64
+ &:hover {
65
+ background: #5a5a5a;
66
+ }
67
+ }
68
+ } @else {
69
+ &::-webkit-scrollbar {
70
+ width: 8px;
71
+ height: 8px;
72
+ }
73
+
74
+ &::-webkit-scrollbar-track {
75
+ background: $light-bg-tertiary;
76
+ border-radius: 4px;
77
+ }
78
+
79
+ &::-webkit-scrollbar-thumb {
80
+ background: #c0c0c0;
81
+ border-radius: 4px;
82
+
83
+ &:hover {
84
+ background: #a0a0a0;
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+
91
+ // ============================================================================
92
+ // Base Container Styles
93
+ // ============================================================================
94
+
95
+ @mixin test-page-container($theme: 'light') {
96
+ width: 100%;
97
+ height: 100vh;
98
+ overflow-y: auto;
99
+ overflow-x: hidden;
100
+
101
+ @if $theme == 'dark' {
102
+ background-color: $dark-bg-primary;
103
+ color: $dark-text-primary;
104
+ } @else {
105
+ background-color: $light-bg-primary;
106
+ color: $light-text-primary;
107
+ }
108
+
109
+ @include custom-scrollbar($theme);
110
+ }
111
+
112
+
113
+ // ============================================================================
114
+ // Header Styles
115
+ // ============================================================================
116
+
117
+ @mixin test-page-header() {
118
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
119
+ color: white;
120
+ padding: 2rem;
121
+ text-align: center;
122
+
123
+ h1 {
124
+ margin: 0 0 0.5rem 0;
125
+ font-size: 2rem;
126
+ font-weight: 700;
127
+ }
128
+
129
+ .subtitle {
130
+ margin: 0;
131
+ opacity: 0.9;
132
+ font-size: 1.1rem;
133
+ }
134
+ }
135
+
136
+
137
+ // ============================================================================
138
+ // Section Styles
139
+ // ============================================================================
140
+
141
+ @mixin test-section($theme: 'light') {
142
+ border-radius: 8px;
143
+ padding: 20px;
144
+ margin-bottom: 20px;
145
+
146
+ @if $theme == 'dark' {
147
+ background-color: $dark-bg-secondary;
148
+ border: 1px solid $dark-border;
149
+ } @else {
150
+ background-color: $light-bg-secondary;
151
+ border: 1px solid $light-border;
152
+ box-shadow: 0 2px 8px $light-shadow;
153
+ }
154
+
155
+ h2, h3 {
156
+ margin-top: 0;
157
+ margin-bottom: 15px;
158
+ font-weight: 600;
159
+
160
+ @if $theme == 'dark' {
161
+ color: $color-primary;
162
+ } @else {
163
+ color: $light-text-primary;
164
+ }
165
+ }
166
+
167
+ h2 {
168
+ font-size: 20px;
169
+ }
170
+
171
+ h3 {
172
+ font-size: 16px;
173
+ }
174
+
175
+ p {
176
+ margin: 0 0 1rem 0;
177
+
178
+ @if $theme == 'dark' {
179
+ color: $dark-text-secondary;
180
+ } @else {
181
+ color: $light-text-secondary;
182
+ }
183
+ }
184
+ }
185
+
186
+
187
+ // ============================================================================
188
+ // Panel Styles (for sidebars/control panels)
189
+ // ============================================================================
190
+
191
+ @mixin control-panel($theme: 'dark') {
192
+ border-radius: 8px;
193
+ padding: 20px;
194
+ overflow-y: auto;
195
+ max-height: calc(100vh - 80px);
196
+
197
+ @if $theme == 'dark' {
198
+ background-color: $dark-bg-secondary;
199
+ } @else {
200
+ background-color: $light-bg-secondary;
201
+ border: 1px solid $light-border;
202
+ }
203
+
204
+ @include custom-scrollbar($theme);
205
+
206
+ h2 {
207
+ margin: 0 0 20px 0;
208
+ font-size: 20px;
209
+ padding-bottom: 10px;
210
+
211
+ @if $theme == 'dark' {
212
+ color: $color-accent;
213
+ border-bottom: 1px solid $dark-border;
214
+ } @else {
215
+ color: $light-text-primary;
216
+ border-bottom: 1px solid $light-border;
217
+ }
218
+ }
219
+
220
+ h3 {
221
+ margin: 0 0 15px 0;
222
+ font-size: 16px;
223
+ color: $color-primary;
224
+ }
225
+ }
226
+
227
+
228
+ // ============================================================================
229
+ // Button Styles
230
+ // ============================================================================
231
+
232
+ @mixin test-button($variant: 'primary') {
233
+ padding: 0.75rem 1.5rem;
234
+ border: none;
235
+ border-radius: 8px;
236
+ font-weight: 600;
237
+ font-size: 1rem;
238
+ cursor: pointer;
239
+ transition: all 0.3s ease;
240
+
241
+ &:disabled {
242
+ opacity: 0.5;
243
+ cursor: not-allowed;
244
+ }
245
+
246
+ @if $variant == 'primary' {
247
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
248
+ color: white;
249
+
250
+ &:hover:not(:disabled) {
251
+ transform: translateY(-2px);
252
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
253
+ }
254
+ } @else if $variant == 'secondary' {
255
+ background: #6c757d;
256
+ color: white;
257
+
258
+ &:hover:not(:disabled) {
259
+ background: #5a6268;
260
+ }
261
+ } @else if $variant == 'success' {
262
+ background: $color-success;
263
+ color: white;
264
+
265
+ &:hover:not(:disabled) {
266
+ background: darken($color-success, 10%);
267
+ transform: translateY(-2px);
268
+ box-shadow: 0 4px 12px rgba($color-success, 0.4);
269
+ }
270
+ } @else if $variant == 'test' {
271
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
272
+ color: white;
273
+
274
+ &:hover:not(:disabled) {
275
+ transform: translateY(-2px);
276
+ box-shadow: 0 4px 12px rgba(245, 87, 108, 0.4);
277
+ }
278
+ }
279
+ }
280
+
281
+
282
+ // ============================================================================
283
+ // Status Display
284
+ // ============================================================================
285
+
286
+ @mixin status-display($theme: 'light') {
287
+ border-radius: 8px;
288
+ padding: 1rem;
289
+ margin-bottom: 1rem;
290
+
291
+ @if $theme == 'dark' {
292
+ background-color: $dark-bg-primary;
293
+ } @else {
294
+ background-color: $light-bg-tertiary;
295
+ }
296
+
297
+ .status-item {
298
+ display: flex;
299
+ justify-content: space-between;
300
+ padding: 0.5rem 0;
301
+
302
+ @if $theme == 'dark' {
303
+ border-bottom: 1px solid $dark-border;
304
+ } @else {
305
+ border-bottom: 1px solid #e9ecef;
306
+ }
307
+
308
+ &:last-child {
309
+ border-bottom: none;
310
+ }
311
+
312
+ .status-label {
313
+ font-weight: 600;
314
+
315
+ @if $theme == 'dark' {
316
+ color: $dark-text-secondary;
317
+ } @else {
318
+ color: $light-text-secondary;
319
+ }
320
+ }
321
+
322
+ .status-value {
323
+ @if $theme == 'dark' {
324
+ color: $dark-text-primary;
325
+ } @else {
326
+ color: $light-text-primary;
327
+ }
328
+ }
329
+
330
+ &.ready .status-value {
331
+ color: $color-success;
332
+ font-weight: 600;
333
+ }
334
+
335
+ &.loading .status-value {
336
+ color: $color-warning;
337
+ font-weight: 600;
338
+ }
339
+
340
+ &.error .status-value {
341
+ color: $color-error;
342
+ font-weight: 600;
343
+ }
344
+ }
345
+ }
346
+
347
+
348
+ // ============================================================================
349
+ // Error/Success Messages
350
+ // ============================================================================
351
+
352
+ @mixin error-message() {
353
+ background: #fff3cd;
354
+ border: 1px solid #ffeaa7;
355
+ border-radius: 6px;
356
+ padding: 0.75rem;
357
+ color: #856404;
358
+ margin-top: 1rem;
359
+ }
360
+
361
+ @mixin success-message() {
362
+ background: #d4edda;
363
+ border: 1px solid #c3e6cb;
364
+ border-radius: 6px;
365
+ padding: 0.75rem;
366
+ color: #155724;
367
+ font-weight: 600;
368
+ margin-top: 1rem;
369
+ }
370
+
371
+
372
+ // ============================================================================
373
+ // Table Styles
374
+ // ============================================================================
375
+
376
+ @mixin data-table($theme: 'light') {
377
+ width: 100%;
378
+ border-collapse: collapse;
379
+ font-size: 12px;
380
+
381
+ thead {
382
+ position: sticky;
383
+ top: 0;
384
+ z-index: 1;
385
+
386
+ @if $theme == 'dark' {
387
+ background-color: $dark-bg-secondary;
388
+ } @else {
389
+ background-color: $light-bg-tertiary;
390
+ }
391
+
392
+ th {
393
+ padding: 8px 6px;
394
+ text-align: left;
395
+ font-weight: 600;
396
+
397
+ @if $theme == 'dark' {
398
+ color: $color-primary;
399
+ border-bottom: 1px solid $dark-border;
400
+ } @else {
401
+ color: $light-text-secondary;
402
+ border-bottom: 2px solid #dee2e6;
403
+ }
404
+ }
405
+ }
406
+
407
+ tbody {
408
+ tr {
409
+ cursor: pointer;
410
+ transition: background-color 0.2s;
411
+
412
+ @if $theme == 'dark' {
413
+ &:hover {
414
+ background-color: $dark-bg-tertiary;
415
+ }
416
+
417
+ &.selected {
418
+ background-color: #4a3030;
419
+
420
+ td {
421
+ color: $color-accent;
422
+ font-weight: 500;
423
+ }
424
+ }
425
+ } @else {
426
+ &:hover {
427
+ background-color: $light-bg-tertiary;
428
+ }
429
+
430
+ &.selected {
431
+ background-color: #e7f5ff;
432
+
433
+ td {
434
+ color: $color-info;
435
+ font-weight: 500;
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ td {
442
+ padding: 8px 6px;
443
+
444
+ @if $theme == 'dark' {
445
+ color: #c0c0c0;
446
+ border-bottom: 1px solid #303030;
447
+ } @else {
448
+ color: $light-text-primary;
449
+ border-bottom: 1px solid #dee2e6;
450
+ }
451
+ }
452
+ }
453
+ }
454
+
455
+
456
+ // ============================================================================
457
+ // Input Styles
458
+ // ============================================================================
459
+
460
+ @mixin input-field($theme: 'light') {
461
+ width: 100%;
462
+ padding: 0.75rem;
463
+ border-radius: 8px;
464
+ font-size: 1rem;
465
+ transition: border-color 0.3s ease;
466
+
467
+ @if $theme == 'dark' {
468
+ background-color: $dark-bg-primary;
469
+ border: 2px solid $dark-border;
470
+ color: $dark-text-primary;
471
+
472
+ &:focus {
473
+ outline: none;
474
+ border-color: $color-primary;
475
+ }
476
+
477
+ &:disabled {
478
+ background-color: $dark-bg-tertiary;
479
+ cursor: not-allowed;
480
+ }
481
+ } @else {
482
+ background-color: white;
483
+ border: 2px solid #e9ecef;
484
+ color: $light-text-primary;
485
+
486
+ &:focus {
487
+ outline: none;
488
+ border-color: #667eea;
489
+ }
490
+
491
+ &:disabled {
492
+ background-color: $light-bg-tertiary;
493
+ cursor: not-allowed;
494
+ }
495
+ }
496
+ }
497
+
498
+
499
+ // ============================================================================
500
+ // Code/Debug Display
501
+ // ============================================================================
502
+
503
+ @mixin code-display($theme: 'light') {
504
+ border-radius: 8px;
505
+ padding: 1rem;
506
+ font-family: "Courier New", monospace;
507
+ font-size: 0.9rem;
508
+ overflow-x: auto;
509
+ white-space: pre-wrap;
510
+ word-break: break-word;
511
+ margin: 0;
512
+
513
+ @if $theme == 'dark' {
514
+ background-color: $dark-bg-primary;
515
+ color: $dark-text-primary;
516
+ border: 1px solid $dark-border;
517
+ } @else {
518
+ background-color: white;
519
+ color: $light-text-primary;
520
+ border: 1px solid $light-border;
521
+ }
522
+ }
523
+
524
+
525
+ // ============================================================================
526
+ // Utility Classes
527
+ // ============================================================================
528
+
529
+ .no-data-message {
530
+ text-align: center;
531
+ padding: 2rem;
532
+ font-size: 14px;
533
+ font-style: italic;
534
+ }
535
+
536
+ .panel-section {
537
+ margin-bottom: 30px;
538
+
539
+ &:last-child {
540
+ margin-bottom: 0;
541
+ }
542
+ }
543
+
544
+ .button-group {
545
+ display: flex;
546
+ gap: 0.5rem;
547
+ flex-wrap: wrap;
548
+ }
549
+
550
+ .info-text {
551
+ font-size: 13px;
552
+ line-height: 1.5;
553
+ margin: 0 0 15px 0;
554
+ }
trigo-web/app/src/types/mcts.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TypeScript type definitions for MCTS visualization
3
+ */
4
+
5
+ import type { TrigoGame } from "../../../inc/trigo/game";
6
+ import type { Position } from "../../../inc/trigo/types";
7
+
8
+
9
+ /**
10
+ * Statistics for a single move (position) at a given game state
11
+ */
12
+ export interface MCTSMoveStatistic {
13
+ position: Position | null; // null for pass move
14
+ actionKey: string; // "x,y,z" or "pass"
15
+ N: number; // Visit count
16
+ pi: number; // Search policy (normalized visit count)
17
+ }
18
+
19
+
20
+ /**
21
+ * Complete MCTS data for a single move in the game
22
+ */
23
+ export interface MCTSMoveData {
24
+ moveNumber: number; // Move index (0-based)
25
+ player: "black" | "white"; // Player to move
26
+ gameState: TrigoGame; // Game state at this move
27
+ statistics: MCTSMoveStatistic[]; // Statistics for all legal moves
28
+ }
29
+
30
+
31
+ /**
32
+ * Statistic type for visualization selection
33
+ */
34
+ export type MCTSStatisticType = "N" | "pi";
35
+
36
+
37
+ /**
38
+ * Color scale type for heatmap
39
+ */
40
+ export type ColorScaleType = "linear" | "log";
41
+
42
+
43
+ /**
44
+ * Tree node for D3 visualization
45
+ */
46
+ export interface MCTSTreeNode {
47
+ id: string;
48
+ label: string;
49
+ N: number;
50
+ pi: number;
51
+ children: MCTSTreeNode[];
52
+ }
trigo-web/app/src/utils/mctsColorScale.ts ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Color scale utilities for MCTS visualization
3
+ *
4
+ * Uses D3 color scales to map statistics to colors
5
+ */
6
+
7
+ import * as d3 from "d3";
8
+ import * as d3Chromatic from "d3-scale-chromatic";
9
+ import type { MCTSStatisticType, ColorScaleType } from "@/types/mcts";
10
+
11
+
12
+ /**
13
+ * Get color for a statistic value
14
+ *
15
+ * @param value - The statistic value
16
+ * @param statistic - Which statistic (N or pi)
17
+ * @param min - Minimum value in the range
18
+ * @param max - Maximum value in the range
19
+ * @param scale - Linear or logarithmic scale
20
+ * @returns CSS color string
21
+ */
22
+ export function getColorForStatistic(
23
+ value: number,
24
+ statistic: MCTSStatisticType,
25
+ min: number,
26
+ max: number,
27
+ scale: ColorScaleType = "linear"
28
+ ): string {
29
+ // Handle edge cases
30
+ if (!Number.isFinite(value) || max === min) {
31
+ return "#808080"; // Gray for undefined/invalid
32
+ }
33
+
34
+ // Normalize value to [0, 1]
35
+ let normalized: number;
36
+
37
+ if (scale === "log") {
38
+ // Logarithmic scale (useful for visit counts with wide range)
39
+ const logMin = Math.log(Math.max(min, 1));
40
+ const logMax = Math.log(Math.max(max, 1));
41
+ const logValue = Math.log(Math.max(value, 1));
42
+ normalized = (logValue - logMin) / (logMax - logMin);
43
+ } else {
44
+ // Linear scale
45
+ normalized = (value - min) / (max - min);
46
+ }
47
+
48
+ // Clamp to [0, 1]
49
+ normalized = Math.max(0, Math.min(1, normalized));
50
+
51
+ // Apply color scale based on statistic type
52
+ switch (statistic) {
53
+ case "N":
54
+ // Visit counts: Blue scale (light to dark)
55
+ return d3Chromatic.interpolateBlues(normalized * 0.9 + 0.1);
56
+
57
+ case "pi":
58
+ // Search policy: Yellow-Orange-Red scale
59
+ return d3Chromatic.interpolateYlOrRd(normalized * 0.9 + 0.1);
60
+
61
+ default:
62
+ return "#808080";
63
+ }
64
+ }
65
+
66
+
67
+ /**
68
+ * Create a D3 color scale for a statistic
69
+ *
70
+ * @param statistic - Which statistic
71
+ * @param min - Minimum value
72
+ * @param max - Maximum value
73
+ * @param scaleType - Linear or logarithmic
74
+ * @returns D3 scale function
75
+ */
76
+ export function createColorScale(
77
+ statistic: MCTSStatisticType,
78
+ min: number,
79
+ max: number,
80
+ scaleType: ColorScaleType = "linear"
81
+ ) {
82
+ // Create domain based on scale type
83
+ let domain: number[];
84
+
85
+ if (scaleType === "log") {
86
+ const logMin = Math.log(Math.max(min, 1));
87
+ const logMax = Math.log(Math.max(max, 1));
88
+ domain = [logMin, logMax];
89
+ } else {
90
+ domain = [min, max];
91
+ }
92
+
93
+ // Create range based on statistic
94
+ let range: string[];
95
+
96
+ switch (statistic) {
97
+ case "N":
98
+ // Blues
99
+ range = [d3Chromatic.interpolateBlues(0.1), d3Chromatic.interpolateBlues(0.9)];
100
+ break;
101
+
102
+ case "pi":
103
+ // Yellow-Orange-Red
104
+ range = [d3Chromatic.interpolateYlOrRd(0.1), d3Chromatic.interpolateYlOrRd(0.9)];
105
+ break;
106
+
107
+ default:
108
+ range = ["#e0e0e0", "#404040"];
109
+ }
110
+
111
+ // Create scale
112
+ if (scaleType === "log") {
113
+ return d3.scaleLinear().domain(domain).range(range);
114
+ } else {
115
+ return d3.scaleLinear().domain(domain).range(range);
116
+ }
117
+ }
118
+
119
+
120
+ /**
121
+ * Generate legend color stops for display
122
+ *
123
+ * @param statistic - Which statistic
124
+ * @param min - Minimum value
125
+ * @param max - Maximum value
126
+ * @param steps - Number of color stops (default: 10)
127
+ * @returns Array of {value, color} objects
128
+ */
129
+ export function generateLegendStops(
130
+ statistic: MCTSStatisticType,
131
+ min: number,
132
+ max: number,
133
+ steps: number = 10
134
+ ): Array<{ value: number; color: string; label: string }> {
135
+ const stops: Array<{ value: number; color: string; label: string }> = [];
136
+
137
+ for (let i = 0; i <= steps; i++) {
138
+ const t = i / steps;
139
+ const value = min + (max - min) * t;
140
+ const color = getColorForStatistic(value, statistic, min, max, "linear");
141
+
142
+ // Format label based on statistic type
143
+ let label: string;
144
+ if (statistic === "pi") {
145
+ label = `${(value * 100).toFixed(1)}%`;
146
+ } else {
147
+ label = value < 10 ? value.toFixed(1) : Math.round(value).toString();
148
+ }
149
+
150
+ stops.push({ value, color, label });
151
+ }
152
+
153
+ return stops;
154
+ }
155
+
156
+
157
+ /**
158
+ * Get appropriate label for a statistic
159
+ */
160
+ export function getStatisticLabel(statistic: MCTSStatisticType): string {
161
+ switch (statistic) {
162
+ case "N":
163
+ return "Visit Count";
164
+ case "pi":
165
+ return "Search Policy π";
166
+ default:
167
+ return "Unknown";
168
+ }
169
+ }
170
+
171
+
172
+ /**
173
+ * Get descriptive text for a statistic
174
+ */
175
+ export function getStatisticDescription(statistic: MCTSStatisticType): string {
176
+ switch (statistic) {
177
+ case "N":
178
+ return "Number of times MCTS explored this move";
179
+ case "pi":
180
+ return "Final probability of selecting this move after search";
181
+ default:
182
+ return "";
183
+ }
184
+ }
trigo-web/app/src/utils/mctsDataParser.ts ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * MCTS Data Parser
3
+ *
4
+ * Parses TGN game files and visit count JSON files to reconstruct
5
+ * move-by-move MCTS statistics.
6
+ */
7
+
8
+ import { TrigoGame } from "../../../inc/trigo/game";
9
+ import type { MCTSMoveData, MCTSMoveStatistic } from "../types/mcts";
10
+
11
+
12
+ /**
13
+ * Parse MCTS data from TGN and visit counts JSON
14
+ *
15
+ * @param tgnContent - TGN game notation string
16
+ * @param visitCountsJson - JSON string containing visit counts array
17
+ * @returns Array of MCTS move data for each move in the game
18
+ * @throws Error if parsing fails or data is invalid
19
+ */
20
+ export async function parseMCTSData(
21
+ tgnContent: string,
22
+ visitCountsJson: string
23
+ ): Promise<MCTSMoveData[]> {
24
+ // 1. Parse TGN to get game
25
+ let game: TrigoGame;
26
+ try {
27
+ game = TrigoGame.fromTGN(tgnContent);
28
+ } catch (error) {
29
+ throw new Error(`Failed to parse TGN file: ${error instanceof Error ? error.message : String(error)}`);
30
+ }
31
+
32
+ // 2. Validate board shape (must be 2D)
33
+ const shape = game.getShape();
34
+ if (shape.z !== 1) {
35
+ throw new Error(
36
+ `Only 2D boards (z=1) are supported. This game has shape ${shape.x}×${shape.y}×${shape.z}`
37
+ );
38
+ }
39
+
40
+ // 3. Parse visit counts JSON
41
+ let visitCounts: number[][];
42
+ try {
43
+ visitCounts = JSON.parse(visitCountsJson);
44
+
45
+ if (!Array.isArray(visitCounts)) {
46
+ throw new Error("Visit counts must be an array");
47
+ }
48
+
49
+ if (visitCounts.length === 0) {
50
+ throw new Error("Visit counts array is empty");
51
+ }
52
+
53
+ // Validate that each element is an array of numbers
54
+ for (let i = 0; i < visitCounts.length; i++) {
55
+ if (!Array.isArray(visitCounts[i])) {
56
+ throw new Error(`Visit counts at index ${i} is not an array`);
57
+ }
58
+ }
59
+ } catch (error) {
60
+ throw new Error(`Failed to parse visit counts JSON: ${error instanceof Error ? error.message : String(error)}`);
61
+ }
62
+
63
+ // 4. Replay game move-by-move and map visit counts to positions
64
+ const result: MCTSMoveData[] = [];
65
+ const replayGame = new TrigoGame(shape, {});
66
+ const stepHistory = game.getHistory();
67
+
68
+ // Ensure we have visit counts for each move
69
+ if (visitCounts.length !== stepHistory.length) {
70
+ console.warn(
71
+ `Visit counts length (${visitCounts.length}) doesn't match step history length (${stepHistory.length}). Using minimum.`
72
+ );
73
+ }
74
+
75
+ const minLength = Math.min(visitCounts.length, stepHistory.length);
76
+
77
+ for (let i = 0; i < minLength; i++) {
78
+ // Get current player
79
+ const currentPlayer = replayGame.getCurrentPlayer() === 1 ? "black" : "white";
80
+
81
+ // Get all valid positions at this state
82
+ const validPos = replayGame.validMovePositions();
83
+
84
+ // Map visit counts to positions
85
+ const statistics: MCTSMoveStatistic[] = validPos.map((pos, idx) => ({
86
+ position: pos,
87
+ actionKey: `${pos.x},${pos.y},${pos.z}`,
88
+ N: visitCounts[i][idx] || 0,
89
+ pi: 0 // Will be calculated after
90
+ }));
91
+
92
+ // Add pass move (typically the last element in visit counts)
93
+ const passN = visitCounts[i][validPos.length] || 0;
94
+ statistics.push({
95
+ position: null,
96
+ actionKey: "pass",
97
+ N: passN,
98
+ pi: 0
99
+ });
100
+
101
+ // Calculate total visits
102
+ const totalN = statistics.reduce((sum, stat) => sum + stat.N, 0);
103
+
104
+ // Normalize to get search policy π
105
+ if (totalN > 0) {
106
+ statistics.forEach((stat) => {
107
+ stat.pi = stat.N / totalN;
108
+ });
109
+ }
110
+
111
+ // Store move data
112
+ result.push({
113
+ moveNumber: i,
114
+ player: currentPlayer,
115
+ gameState: replayGame.clone(),
116
+ statistics
117
+ });
118
+
119
+ // Apply move to advance game state
120
+ const step = stepHistory[i];
121
+ if (step.type === 1) { // StepType.PASS
122
+ replayGame.pass();
123
+ } else if (step.position) {
124
+ replayGame.drop(step.position);
125
+ }
126
+ }
127
+
128
+ if (result.length === 0) {
129
+ throw new Error("No MCTS data could be parsed");
130
+ }
131
+
132
+ console.log(`[MCTS Parser] Successfully parsed ${result.length} moves`);
133
+ return result;
134
+ }
135
+
136
+
137
+ /**
138
+ * Validate that visit counts data has the expected structure
139
+ */
140
+ export function validateVisitCounts(visitCounts: any): visitCounts is number[][] {
141
+ if (!Array.isArray(visitCounts)) {
142
+ return false;
143
+ }
144
+
145
+ for (const counts of visitCounts) {
146
+ if (!Array.isArray(counts)) {
147
+ return false;
148
+ }
149
+
150
+ for (const count of counts) {
151
+ if (typeof count !== "number" || !Number.isFinite(count) || count < 0) {
152
+ return false;
153
+ }
154
+ }
155
+ }
156
+
157
+ return true;
158
+ }
159
+
160
+
161
+ /**
162
+ * Format position for display
163
+ */
164
+ export function formatPosition(position: { x: number; y: number; z?: number } | null): string {
165
+ if (!position) {
166
+ return "Pass";
167
+ }
168
+
169
+ // Convert to chess-like notation: column letter + row number
170
+ const col = String.fromCharCode(65 + position.x); // A, B, C, ...
171
+ const row = position.y + 1; // 1, 2, 3, ...
172
+
173
+ return `${col}${row}`;
174
+ }
trigo-web/app/src/utils/storage.ts CHANGED
@@ -32,6 +32,9 @@ export enum StorageKey {
32
  // AI settings
33
  AI_PLAYER_COLOR = "trigoAIPlayerColor",
34
 
 
 
 
35
  // User preferences (examples for future use)
36
  // THEME = "trigoTheme",
37
  // LANGUAGE = "trigoLanguage",
 
32
  // AI settings
33
  AI_PLAYER_COLOR = "trigoAIPlayerColor",
34
 
35
+ // Player settings
36
+ PLAYER_NICKNAME = "trigoPlayerNickname",
37
+
38
  // User preferences (examples for future use)
39
  // THEME = "trigoTheme",
40
  // LANGUAGE = "trigoLanguage",
trigo-web/app/src/views/MCTSAnalysisView.vue ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="mcts-analysis-container">
3
+ <h1>MCTS Analysis</h1>
4
+
5
+ <div class="mcts-grid">
6
+ <!-- Left sidebar: Control Panel -->
7
+ <div class="control-panel">
8
+ <h2>Controls</h2>
9
+
10
+ <!-- Data Loader Section -->
11
+ <section class="panel-section">
12
+ <h3>Data Source</h3>
13
+ <p class="info-text">
14
+ Upload a TGN game file and its corresponding visit counts JSON to analyze MCTS
15
+ behavior. Only 2D boards are supported.
16
+ </p>
17
+ <MCTSDataLoader />
18
+ </section>
19
+
20
+ <!-- Move Navigation Section -->
21
+ <section class="panel-section">
22
+ <h3>Navigation</h3>
23
+ <MCTSMoveNavigation v-if="hasData" />
24
+ <div v-else class="nav-placeholder">
25
+ <p>Load data to enable navigation</p>
26
+ </div>
27
+ </section>
28
+
29
+ <!-- Visualization Settings Section -->
30
+ <section class="panel-section">
31
+ <h3>Statistics</h3>
32
+ <MCTSStatisticsPanel />
33
+ </section>
34
+ </div>
35
+
36
+ <!-- Right content area -->
37
+ <div class="content-area">
38
+ <!-- Tree Visualization -->
39
+ <div class="visualization-panel tree-panel">
40
+ <h3>Search Tree</h3>
41
+ <MCTSTreeVisualization />
42
+ </div>
43
+
44
+ <!-- Board Heatmap -->
45
+ <div class="visualization-panel heatmap-panel">
46
+ <h3>Board Heatmap</h3>
47
+ <MCTSBoardHeatmap />
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ </template>
53
+
54
+ <script setup lang="ts">
55
+ import { ref, computed, onMounted } from "vue";
56
+ import { useMCTSStore } from "@/stores/mctsStore";
57
+ import MCTSDataLoader from "@/components/mcts/MCTSDataLoader.vue";
58
+ import MCTSMoveNavigation from "@/components/mcts/MCTSMoveNavigation.vue";
59
+ import MCTSStatisticsPanel from "@/components/mcts/MCTSStatisticsPanel.vue";
60
+ import MCTSBoardHeatmap from "@/components/mcts/MCTSBoardHeatmap.vue";
61
+ import MCTSTreeVisualization from "@/components/mcts/MCTSTreeVisualization.vue";
62
+
63
+ const mctsStore = useMCTSStore();
64
+
65
+ const hasData = computed(() => mctsStore.hasData);
66
+
67
+ onMounted(() => {
68
+ console.log("[MCTS Analysis] View mounted");
69
+ });
70
+ </script>
71
+
72
+ <style scoped lang="scss">
73
+ @use "@/styles/test-pages.scss" as *;
74
+
75
+ .mcts-analysis-container {
76
+ @include test-page-container('dark');
77
+ padding: 20px;
78
+
79
+ h1 {
80
+ margin: 0 0 20px 0;
81
+ font-size: 28px;
82
+ color: #e94560;
83
+ }
84
+ }
85
+
86
+ .mcts-grid {
87
+ display: grid;
88
+ grid-template-columns: 320px 1fr;
89
+ gap: 20px;
90
+ min-height: calc(100vh - 80px);
91
+ }
92
+
93
+ /* ============================================================================
94
+ Control Panel (Left Sidebar)
95
+ ============================================================================ */
96
+
97
+ .control-panel {
98
+ @include control-panel('dark');
99
+ }
100
+
101
+ .panel-section {
102
+ @extend .panel-section;
103
+ }
104
+
105
+ .info-text {
106
+ @extend .info-text;
107
+ color: #a0a0a0;
108
+ }
109
+
110
+ .nav-placeholder {
111
+ padding: 15px;
112
+ background-color: #1a1a1a;
113
+ border-radius: 4px;
114
+ border: 1px dashed #404040;
115
+ text-align: center;
116
+ color: #606060;
117
+ font-size: 12px;
118
+ }
119
+
120
+ /* ============================================================================
121
+ Content Area (Right Side)
122
+ ============================================================================ */
123
+
124
+ .content-area {
125
+ display: grid;
126
+ grid-template-rows: auto 1fr;
127
+ gap: 20px;
128
+ overflow: hidden;
129
+ }
130
+
131
+ .visualization-panel {
132
+ @include test-section('dark');
133
+ display: flex;
134
+ flex-direction: column;
135
+ min-height: 0;
136
+ padding: 20px;
137
+
138
+ h3 {
139
+ margin: 0 0 15px 0;
140
+ font-size: 18px;
141
+ color: #4a90e2;
142
+ flex-shrink: 0;
143
+ }
144
+ }
145
+
146
+ .tree-panel {
147
+ overflow: visible;
148
+ }
149
+
150
+ /* ============================================================================
151
+ Responsive
152
+ ============================================================================ */
153
+
154
+ @media (max-width: 1200px) {
155
+ .mcts-grid {
156
+ grid-template-columns: 280px 1fr;
157
+ }
158
+ }
159
+
160
+ @media (max-width: 900px) {
161
+ .mcts-grid {
162
+ grid-template-columns: 1fr;
163
+ grid-template-rows: auto 1fr;
164
+ min-height: auto;
165
+ }
166
+
167
+ .control-panel {
168
+ max-height: 400px;
169
+ }
170
+
171
+ .content-area {
172
+ min-height: 800px;
173
+ max-height: none;
174
+ }
175
+ }
176
+ </style>
trigo-web/app/src/views/OnnxTestView.vue CHANGED
@@ -179,9 +179,9 @@
179
  console.log("=".repeat(80));
180
 
181
  inferencer = new OnnxInferencer({
182
- modelPath: "/onnx/GPT2CausalLM_ep0015_int8_seq2048_int8.onnx",
183
- vocabSize: 259,
184
- seqLen: 2048,
185
  executionProviders: ["wasm"] // Use WebAssembly for compatibility
186
  });
187
 
 
179
  console.log("=".repeat(80));
180
 
181
  inferencer = new OnnxInferencer({
182
+ modelPath: import.meta.env.VITE_ONNX_TREE_MODEL,
183
+ vocabSize: 128,
184
+ seqLen: 256,
185
  executionProviders: ["wasm"] // Use WebAssembly for compatibility
186
  });
187
 
trigo-web/app/src/views/SocketTestView.vue ADDED
@@ -0,0 +1,932 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="socket-test-view">
3
+ <div class="test-header">
4
+ <h1>Socket API Test Page</h1>
5
+ <p class="subtitle">Testing Backend Game Socket Handlers</p>
6
+ </div>
7
+
8
+ <div class="test-body">
9
+ <!-- Left Column: Controls and Status -->
10
+ <div class="left-column">
11
+ <!-- Connection Status -->
12
+ <div class="test-section">
13
+ <h2>Connection Status</h2>
14
+ <div class="status-info">
15
+ <div class="status-item" :class="{ ready: connected, error: error }">
16
+ <span class="status-label">Socket:</span>
17
+ <span class="status-value">
18
+ {{ connected ? "✓ Connected" : "⚪ Disconnected" }}
19
+ </span>
20
+ </div>
21
+ <div v-if="socket" class="status-item">
22
+ <span class="status-label">Socket ID:</span>
23
+ <span class="status-value code">{{ socket.id }}</span>
24
+ </div>
25
+ <div v-if="error" class="error-message">❌ Error: {{ error }}</div>
26
+ </div>
27
+ </div>
28
+
29
+ <!-- Room Management -->
30
+ <div class="test-section">
31
+ <h2>Room Management</h2>
32
+ <div class="room-info">
33
+ <div class="info-item">
34
+ <span class="label">Current Room:</span>
35
+ <span class="value code">{{ currentRoomId || "Not in room" }}</span>
36
+ </div>
37
+ <div v-if="playerColor" class="info-item">
38
+ <span class="label">Player Color:</span>
39
+ <span class="value" :class="playerColor">{{ playerColor }}</span>
40
+ </div>
41
+ <div v-if="currentRoomId" class="info-item">
42
+ <span class="label">Room Admin:</span>
43
+ <span class="value">{{ isAdmin ? "You (Admin)" : "Other player" }}</span>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="room-controls">
48
+ <div class="control-group">
49
+ <input
50
+ v-model="nickname"
51
+ type="text"
52
+ placeholder="Nickname"
53
+ class="input-text"
54
+ />
55
+ <input
56
+ v-model="roomIdInput"
57
+ type="text"
58
+ placeholder="Room ID (leave empty to create)"
59
+ class="input-text"
60
+ />
61
+ </div>
62
+ <div class="button-group">
63
+ <button
64
+ class="btn btn-primary"
65
+ @click="testJoinRoom"
66
+ :disabled="!connected || currentRoomId !== null"
67
+ >
68
+ Join/Create Room
69
+ </button>
70
+ <button
71
+ class="btn btn-secondary"
72
+ @click="testLeaveRoom"
73
+ :disabled="!currentRoomId"
74
+ >
75
+ Leave Room
76
+ </button>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <!-- Game State Display -->
82
+ <div class="test-section">
83
+ <h2>Game State</h2>
84
+ <div class="game-info">
85
+ <div class="info-item">
86
+ <span class="label">Game Status:</span>
87
+ <span class="value">{{ gameState.gameStatus || "N/A" }}</span>
88
+ </div>
89
+ <div class="info-item">
90
+ <span class="label">Current Player:</span>
91
+ <span class="value" :class="gameState.currentPlayer">
92
+ {{ gameState.currentPlayer || "N/A" }}
93
+ </span>
94
+ </div>
95
+ <div class="info-item">
96
+ <span class="label">Current Index:</span>
97
+ <span class="value">{{ gameState.currentMoveIndex }}</span>
98
+ </div>
99
+ <div class="info-item">
100
+ <span class="label">Captured (Black):</span>
101
+ <span class="value">{{ gameState.capturedStones.black }}</span>
102
+ </div>
103
+ <div class="info-item">
104
+ <span class="label">Captured (White):</span>
105
+ <span class="value">{{ gameState.capturedStones.white }}</span>
106
+ </div>
107
+ </div>
108
+
109
+ <!-- TGN Display -->
110
+ <div v-if="gameState.tgn" class="tgn-display">
111
+ <h3>Current TGN:</h3>
112
+ <pre class="tgn-content">{{ gameState.tgn }}</pre>
113
+ </div>
114
+ </div>
115
+
116
+ <!-- Game Actions -->
117
+ <div class="test-section">
118
+ <h2>Game Actions</h2>
119
+ <div class="game-controls">
120
+ <div class="control-group">
121
+ <input
122
+ v-model.number="moveX"
123
+ type="number"
124
+ placeholder="X"
125
+ class="input-number"
126
+ min="0"
127
+ max="4"
128
+ />
129
+ <input
130
+ v-model.number="moveY"
131
+ type="number"
132
+ placeholder="Y"
133
+ class="input-number"
134
+ min="0"
135
+ max="4"
136
+ />
137
+ <input
138
+ v-model.number="moveZ"
139
+ type="number"
140
+ placeholder="Z"
141
+ class="input-number"
142
+ min="0"
143
+ max="4"
144
+ />
145
+ <button
146
+ class="btn btn-primary"
147
+ @click="testMakeMove"
148
+ :disabled="!currentRoomId"
149
+ >
150
+ Make Move
151
+ </button>
152
+ </div>
153
+ <div class="button-group">
154
+ <button
155
+ class="btn btn-secondary"
156
+ @click="testPass"
157
+ :disabled="!currentRoomId"
158
+ >
159
+ Pass
160
+ </button>
161
+ <button
162
+ class="btn btn-warning"
163
+ @click="testResign"
164
+ :disabled="!currentRoomId"
165
+ >
166
+ Resign
167
+ </button>
168
+ </div>
169
+ </div>
170
+ </div>
171
+
172
+ <!-- Admin Actions -->
173
+ <div class="test-section admin-section">
174
+ <h2>Admin Actions (Undo/Redo/Reset)</h2>
175
+ <p class="section-desc">Test the new admin APIs</p>
176
+ <div v-if="!isAdmin && currentRoomId" class="admin-warning">
177
+ ⚠️ Only the room admin can reset the game
178
+ </div>
179
+ <div class="button-group">
180
+ <button
181
+ class="btn btn-admin"
182
+ @click="testUndo"
183
+ :disabled="!currentRoomId || gameState.currentMoveIndex === 0"
184
+ >
185
+ Undo Move
186
+ </button>
187
+ <button
188
+ class="btn btn-admin"
189
+ @click="testRedo"
190
+ :disabled="!currentRoomId"
191
+ >
192
+ Redo Move
193
+ </button>
194
+ </div>
195
+ <div class="reset-controls">
196
+ <div class="control-group">
197
+ <label class="input-label">Board Shape:</label>
198
+ <input
199
+ v-model.number="boardShapeX"
200
+ type="number"
201
+ placeholder="X"
202
+ class="input-number"
203
+ min="3"
204
+ max="9"
205
+ />
206
+ <input
207
+ v-model.number="boardShapeY"
208
+ type="number"
209
+ placeholder="Y"
210
+ class="input-number"
211
+ min="3"
212
+ max="9"
213
+ />
214
+ <input
215
+ v-model.number="boardShapeZ"
216
+ type="number"
217
+ placeholder="Z"
218
+ class="input-number"
219
+ min="3"
220
+ max="9"
221
+ />
222
+ </div>
223
+ <div v-if="roomPlayers.length === 2" class="control-group">
224
+ <label class="checkbox-label">
225
+ <input type="checkbox" v-model="swapColors" />
226
+ Swap Player Colors (Black ↔ White)
227
+ </label>
228
+ </div>
229
+ <button class="btn btn-danger" @click="testReset" :disabled="!currentRoomId || !isAdmin">
230
+ Reset Game (Admin Only)
231
+ </button>
232
+ </div>
233
+ </div>
234
+
235
+ </div>
236
+ <!-- End Left Column -->
237
+
238
+ <!-- Right Column: Event Log -->
239
+ <div class="right-column">
240
+ <!-- Event Log -->
241
+ <div class="test-section event-log-section">
242
+ <h2>Event Log</h2>
243
+ <div class="event-log-controls">
244
+ <button class="btn btn-secondary" @click="clearEventLog">Clear Log</button>
245
+ <label class="checkbox-label">
246
+ <input type="checkbox" v-model="autoScroll" />
247
+ Auto-scroll
248
+ </label>
249
+ </div>
250
+ <div class="event-log" ref="eventLogRef">
251
+ <div v-if="eventLog.length === 0" class="empty-state">No events yet</div>
252
+ <div
253
+ v-for="(event, index) in eventLog"
254
+ :key="index"
255
+ class="event-item"
256
+ :class="event.type"
257
+ >
258
+ <span class="event-timestamp">{{ event.timestamp }}</span>
259
+ <span class="event-name">{{ event.name }}</span>
260
+ <span class="event-type-badge">{{ event.type }}</span>
261
+ <pre class="event-data">{{ JSON.stringify(event.data, null, 2) }}</pre>
262
+ </div>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ <!-- End Right Column -->
267
+
268
+ </div>
269
+ </div>
270
+ </template>
271
+
272
+ <script setup lang="ts">
273
+ import { ref, reactive, onMounted, onUnmounted, nextTick, watch } from "vue";
274
+ import { useSocket } from "@/composables/useSocket";
275
+
276
+ // Socket composable
277
+ const { socket, connected, error } = useSocket();
278
+
279
+ // Room state
280
+ const currentRoomId = ref<string | null>(null);
281
+ const playerColor = ref<string | null>(null);
282
+ const nickname = ref("TestUser");
283
+ const roomIdInput = ref("");
284
+ const isAdmin = ref(false);
285
+ const roomPlayers = ref<string[]>([]);
286
+ const playerColorAssignments = reactive<{ [playerId: string]: "black" | "white" }>({});
287
+ const swapColors = ref(false);
288
+
289
+ // Game state
290
+ const gameState = reactive({
291
+ currentPlayer: null as string | null,
292
+ currentMoveIndex: 0,
293
+ capturedStones: { black: 0, white: 0 },
294
+ gameStatus: null as string | null,
295
+ tgn: "" as string
296
+ });
297
+
298
+ // Move inputs
299
+ const moveX = ref(0);
300
+ const moveY = ref(0);
301
+ const moveZ = ref(0);
302
+
303
+ // Board shape inputs for reset
304
+ const boardShapeX = ref(5);
305
+ const boardShapeY = ref(5);
306
+ const boardShapeZ = ref(5);
307
+
308
+ // Event log
309
+ const eventLog = ref<
310
+ Array<{
311
+ timestamp: string;
312
+ name: string;
313
+ type: "sent" | "received" | "error";
314
+ data: any;
315
+ }>
316
+ >([]);
317
+ const eventLogRef = ref<HTMLElement | null>(null);
318
+ const autoScroll = ref(true);
319
+
320
+ // Helper to log events
321
+ function logEvent(name: string, data: any, type: "sent" | "received" | "error" = "received") {
322
+ const timestamp = new Date().toLocaleTimeString();
323
+ eventLog.value.push({ timestamp, name, type, data });
324
+
325
+ if (autoScroll.value) {
326
+ nextTick(() => {
327
+ if (eventLogRef.value) {
328
+ eventLogRef.value.scrollTop = eventLogRef.value.scrollHeight;
329
+ }
330
+ });
331
+ }
332
+ }
333
+
334
+ function clearEventLog() {
335
+ eventLog.value = [];
336
+ }
337
+
338
+ // Room management
339
+ function testJoinRoom() {
340
+ const data = {
341
+ roomId: roomIdInput.value || undefined,
342
+ nickname: nickname.value
343
+ };
344
+ logEvent("joinRoom", data, "sent");
345
+
346
+ socket.emit("joinRoom", data, (response: any) => {
347
+ logEvent("joinRoom:response", response, response.success ? "received" : "error");
348
+
349
+ if (response.success) {
350
+ currentRoomId.value = response.roomId;
351
+ playerColor.value = response.playerColor;
352
+ isAdmin.value = response.isAdmin || false;
353
+
354
+ // Initialize room players from response
355
+ if (response.players) {
356
+ roomPlayers.value = Object.keys(response.players);
357
+ // Initialize player color assignments
358
+ for (const [playerId, playerInfo] of Object.entries(response.players) as any) {
359
+ playerColorAssignments[playerId] = playerInfo.color;
360
+ }
361
+ }
362
+
363
+ // Update game state
364
+ if (response.gameState) {
365
+ Object.assign(gameState, response.gameState);
366
+
367
+ // Update board shape inputs
368
+ if (response.gameState.boardShape) {
369
+ boardShapeX.value = response.gameState.boardShape.x;
370
+ boardShapeY.value = response.gameState.boardShape.y;
371
+ boardShapeZ.value = response.gameState.boardShape.z;
372
+ }
373
+ }
374
+ }
375
+ });
376
+ }
377
+
378
+ function testLeaveRoom() {
379
+ logEvent("leaveRoom", {}, "sent");
380
+ socket.emit("leaveRoom");
381
+
382
+ // Reset local state
383
+ currentRoomId.value = null;
384
+ playerColor.value = null;
385
+ Object.assign(gameState, {
386
+ currentPlayer: null,
387
+ currentMoveIndex: 0,
388
+ capturedStones: { black: 0, white: 0 },
389
+ gameStatus: null,
390
+ tgn: ""
391
+ });
392
+ }
393
+
394
+ // Game actions
395
+ function testMakeMove() {
396
+ const data = { x: moveX.value, y: moveY.value, z: moveZ.value };
397
+ logEvent("makeMove", data, "sent");
398
+ socket.emit("makeMove", data);
399
+ }
400
+
401
+ function testPass() {
402
+ logEvent("pass", {}, "sent");
403
+ socket.emit("pass");
404
+ }
405
+
406
+ function testResign() {
407
+ if (!confirm("Are you sure you want to resign?")) return;
408
+ logEvent("resign", {}, "sent");
409
+ socket.emit("resign");
410
+ }
411
+
412
+ // Admin actions
413
+ function testUndo() {
414
+ logEvent("undoMove", {}, "sent");
415
+ socket.emit("undoMove", (response: any) => {
416
+ logEvent("undoMove:response", response, response.success ? "received" : "error");
417
+ });
418
+ }
419
+
420
+ function testRedo() {
421
+ logEvent("redoMove", {}, "sent");
422
+ socket.emit("redoMove", (response: any) => {
423
+ logEvent("redoMove:response", response, response.success ? "received" : "error");
424
+ });
425
+ }
426
+
427
+ function testReset() {
428
+ if (!confirm("Reset entire game?")) return;
429
+ const data: any = {
430
+ boardShape: {
431
+ x: boardShapeX.value,
432
+ y: boardShapeY.value,
433
+ z: boardShapeZ.value
434
+ }
435
+ };
436
+
437
+ // Apply color swap if checkbox is checked
438
+ if (swapColors.value && roomPlayers.value.length === 2) {
439
+ const [player1, player2] = roomPlayers.value;
440
+ const color1 = playerColorAssignments[player1];
441
+ const color2 = playerColorAssignments[player2];
442
+
443
+ // Swap the colors
444
+ data.playerColors = {
445
+ [player1]: color2,
446
+ [player2]: color1
447
+ };
448
+ }
449
+
450
+ logEvent("resetGame", data, "sent");
451
+ socket.emit("resetGame", data, (response: any) => {
452
+ logEvent("resetGame:response", response, response.success ? "received" : "error");
453
+ });
454
+ }
455
+
456
+ // Socket event listeners
457
+ onMounted(() => {
458
+ // Listen to gameUpdate
459
+ socket.on("gameUpdate", (data: any) => {
460
+ logEvent("gameUpdate", data, "received");
461
+
462
+ // Update local game state
463
+ if (data.currentPlayer !== undefined) gameState.currentPlayer = data.currentPlayer;
464
+ if (data.currentMoveIndex !== undefined) gameState.currentMoveIndex = data.currentMoveIndex;
465
+ if (data.capturedStones !== undefined) gameState.capturedStones = data.capturedStones;
466
+ if (data.tgn !== undefined) gameState.tgn = data.tgn;
467
+ });
468
+
469
+ // Listen to gameReset
470
+ socket.on("gameReset", (data: any) => {
471
+ logEvent("gameReset", data, "received");
472
+
473
+ // Reset local game state
474
+ gameState.currentPlayer = data.currentPlayer;
475
+ gameState.currentMoveIndex = 0;
476
+ gameState.capturedStones = { black: 0, white: 0 };
477
+ gameState.tgn = data.tgn || "";
478
+
479
+ // Update board shape inputs
480
+ if (data.boardShape) {
481
+ boardShapeX.value = data.boardShape.x;
482
+ boardShapeY.value = data.boardShape.y;
483
+ boardShapeZ.value = data.boardShape.z;
484
+ }
485
+
486
+ // Update player list and colors if provided
487
+ if (data.players) {
488
+ roomPlayers.value = Object.keys(data.players);
489
+ // Initialize player color assignments
490
+ for (const [playerId, playerInfo] of Object.entries(data.players) as any) {
491
+ playerColorAssignments[playerId] = playerInfo.color;
492
+ }
493
+
494
+ // Update current player's color display
495
+ if (data.players[socket.id]) {
496
+ playerColor.value = data.players[socket.id].color;
497
+ }
498
+ }
499
+ });
500
+
501
+ // Listen to gameEnded
502
+ socket.on("gameEnded", (data: any) => {
503
+ logEvent("gameEnded", data, "received");
504
+ gameState.gameStatus = "finished";
505
+ });
506
+
507
+ // Listen to playerJoined
508
+ socket.on("playerJoined", (data: any) => {
509
+ logEvent("playerJoined", data, "received");
510
+
511
+ // Add the new player to the room players list
512
+ if (data.playerId && !roomPlayers.value.includes(data.playerId)) {
513
+ roomPlayers.value.push(data.playerId);
514
+ // Default the new player to white (second player)
515
+ playerColorAssignments[data.playerId] = "white";
516
+ }
517
+ });
518
+
519
+ // Listen to playerLeft
520
+ socket.on("playerLeft", (data: any) => {
521
+ logEvent("playerLeft", data, "received");
522
+ });
523
+
524
+ // Listen to nicknameChanged
525
+ socket.on("nicknameChanged", (data: any) => {
526
+ logEvent("nicknameChanged", data, "received");
527
+ });
528
+
529
+ // Listen to error events
530
+ socket.on("error", (data: any) => {
531
+ logEvent("error", data, "error");
532
+ });
533
+ });
534
+
535
+ onUnmounted(() => {
536
+ // Clean up listeners
537
+ socket.off("gameUpdate");
538
+ socket.off("gameReset");
539
+ socket.off("gameEnded");
540
+ socket.off("playerJoined");
541
+ socket.off("playerLeft");
542
+ socket.off("nicknameChanged");
543
+ socket.off("error");
544
+ });
545
+ </script>
546
+
547
+ <style scoped>
548
+ .socket-test-view {
549
+ max-width: 1200px;
550
+ margin: 0 auto;
551
+ padding: 20px;
552
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
553
+ }
554
+
555
+ .test-header {
556
+ text-align: center;
557
+ margin-bottom: 30px;
558
+ padding-bottom: 20px;
559
+ border-bottom: 2px solid #e0e0e0;
560
+ }
561
+
562
+ .test-header h1 {
563
+ margin: 0 0 10px 0;
564
+ font-size: 2em;
565
+ color: #333;
566
+ }
567
+
568
+ .subtitle {
569
+ margin: 0;
570
+ color: #666;
571
+ font-size: 1.1em;
572
+ }
573
+
574
+ .test-body {
575
+ display: grid;
576
+ grid-template-columns: 1fr 1fr;
577
+ gap: 20px;
578
+ overflow: auto;
579
+ max-height: calc(100vh - 200px);
580
+ }
581
+
582
+ .left-column {
583
+ display: flex;
584
+ flex-direction: column;
585
+ gap: 20px;
586
+ overflow-y: auto;
587
+ }
588
+
589
+ .right-column {
590
+ display: flex;
591
+ flex-direction: column;
592
+ min-height: 0;
593
+ }
594
+
595
+ .event-log-section {
596
+ display: flex;
597
+ flex-direction: column;
598
+ height: 100%;
599
+ min-height: 0;
600
+ }
601
+
602
+ .test-section {
603
+ background: #f9f9f9;
604
+ border: 1px solid #ddd;
605
+ border-radius: 8px;
606
+ padding: 20px;
607
+ }
608
+
609
+ .test-section h2 {
610
+ margin: 0 0 15px 0;
611
+ font-size: 1.5em;
612
+ color: #333;
613
+ }
614
+
615
+ .section-desc {
616
+ margin: -10px 0 15px 0;
617
+ color: #666;
618
+ font-size: 0.9em;
619
+ }
620
+
621
+ .admin-section {
622
+ background: #fff8e1;
623
+ border-color: #ffd54f;
624
+ }
625
+
626
+ /* Status */
627
+ .status-info,
628
+ .room-info,
629
+ .game-info {
630
+ display: flex;
631
+ flex-direction: column;
632
+ gap: 10px;
633
+ margin-bottom: 15px;
634
+ }
635
+
636
+ .status-item,
637
+ .info-item {
638
+ display: flex;
639
+ gap: 10px;
640
+ align-items: center;
641
+ padding: 8px;
642
+ background: white;
643
+ border-radius: 4px;
644
+ }
645
+
646
+ .status-item.ready {
647
+ background: #e8f5e9;
648
+ }
649
+
650
+ .status-item.error {
651
+ background: #ffebee;
652
+ }
653
+
654
+ .status-label,
655
+ .label {
656
+ font-weight: 600;
657
+ min-width: 120px;
658
+ }
659
+
660
+ .status-value,
661
+ .value {
662
+ flex: 1;
663
+ }
664
+
665
+ .value.black {
666
+ color: #000;
667
+ font-weight: bold;
668
+ }
669
+
670
+ .value.white {
671
+ color: #999;
672
+ font-weight: bold;
673
+ }
674
+
675
+ .code {
676
+ font-family: "Courier New", monospace;
677
+ background: #f0f0f0;
678
+ padding: 2px 6px;
679
+ border-radius: 3px;
680
+ font-size: 0.9em;
681
+ }
682
+
683
+ .error-message {
684
+ padding: 10px;
685
+ background: #ffebee;
686
+ border: 1px solid #ef5350;
687
+ border-radius: 4px;
688
+ color: #c62828;
689
+ }
690
+
691
+ /* Controls */
692
+ .room-controls,
693
+ .game-controls {
694
+ display: flex;
695
+ flex-direction: column;
696
+ gap: 10px;
697
+ }
698
+
699
+ .control-group,
700
+ .button-group {
701
+ display: flex;
702
+ gap: 10px;
703
+ flex-wrap: wrap;
704
+ align-items: center;
705
+ }
706
+
707
+ .reset-controls {
708
+ display: flex;
709
+ flex-direction: column;
710
+ gap: 10px;
711
+ margin-top: 10px;
712
+ }
713
+
714
+ .input-label {
715
+ font-weight: 600;
716
+ margin-right: 5px;
717
+ }
718
+
719
+ .input-text,
720
+ .input-number,
721
+ .input-select {
722
+ padding: 8px 12px;
723
+ border: 1px solid #ddd;
724
+ border-radius: 4px;
725
+ font-size: 1em;
726
+ flex: 1;
727
+ min-width: 150px;
728
+ }
729
+
730
+ .input-number {
731
+ min-width: 80px;
732
+ max-width: 100px;
733
+ }
734
+
735
+ .input-select {
736
+ min-width: 200px;
737
+ cursor: pointer;
738
+ background: white;
739
+ }
740
+
741
+ .admin-warning {
742
+ padding: 10px;
743
+ background: #fff8e1;
744
+ border: 1px solid #ffd54f;
745
+ border-radius: 4px;
746
+ color: #f57c00;
747
+ font-weight: 500;
748
+ margin-bottom: 10px;
749
+ }
750
+
751
+ .btn {
752
+ padding: 10px 20px;
753
+ border: none;
754
+ border-radius: 4px;
755
+ font-size: 1em;
756
+ cursor: pointer;
757
+ transition: background 0.2s;
758
+ font-weight: 500;
759
+ }
760
+
761
+ .btn:disabled {
762
+ opacity: 0.5;
763
+ cursor: not-allowed;
764
+ }
765
+
766
+ .btn-primary {
767
+ background: #2196f3;
768
+ color: white;
769
+ }
770
+
771
+ .btn-primary:hover:not(:disabled) {
772
+ background: #1976d2;
773
+ }
774
+
775
+ .btn-secondary {
776
+ background: #757575;
777
+ color: white;
778
+ }
779
+
780
+ .btn-secondary:hover:not(:disabled) {
781
+ background: #616161;
782
+ }
783
+
784
+ .btn-warning {
785
+ background: #ff9800;
786
+ color: white;
787
+ }
788
+
789
+ .btn-warning:hover:not(:disabled) {
790
+ background: #f57c00;
791
+ }
792
+
793
+ .btn-danger {
794
+ background: #f44336;
795
+ color: white;
796
+ }
797
+
798
+ .btn-danger:hover:not(:disabled) {
799
+ background: #d32f2f;
800
+ }
801
+
802
+ .btn-admin {
803
+ background: #9c27b0;
804
+ color: white;
805
+ }
806
+
807
+ .btn-admin:hover:not(:disabled) {
808
+ background: #7b1fa2;
809
+ }
810
+
811
+ .checkbox-label {
812
+ display: flex;
813
+ align-items: center;
814
+ gap: 5px;
815
+ cursor: pointer;
816
+ }
817
+
818
+ /* Event Log */
819
+ .event-log-controls {
820
+ display: flex;
821
+ gap: 10px;
822
+ align-items: center;
823
+ margin-bottom: 10px;
824
+ }
825
+
826
+ .event-log {
827
+ flex: 1;
828
+ overflow-y: auto;
829
+ background: white;
830
+ border: 1px solid #ddd;
831
+ border-radius: 4px;
832
+ padding: 10px;
833
+ min-height: 0;
834
+ }
835
+
836
+ .empty-state {
837
+ text-align: center;
838
+ color: #999;
839
+ padding: 20px;
840
+ }
841
+
842
+ /* TGN Display */
843
+ .tgn-display {
844
+ margin-top: 15px;
845
+ }
846
+
847
+ .tgn-display h3 {
848
+ margin: 0 0 10px 0;
849
+ font-size: 1.1em;
850
+ color: #333;
851
+ }
852
+
853
+ .tgn-content {
854
+ background: white;
855
+ border: 1px solid #ddd;
856
+ border-radius: 4px;
857
+ padding: 10px;
858
+ margin: 0;
859
+ font-family: "Courier New", monospace;
860
+ font-size: 0.9em;
861
+ overflow-x: auto;
862
+ white-space: pre-wrap;
863
+ word-wrap: break-word;
864
+ max-height: 200px;
865
+ overflow-y: auto;
866
+ }
867
+
868
+ .event-item {
869
+ margin-bottom: 10px;
870
+ padding: 10px;
871
+ border-radius: 4px;
872
+ border-left: 4px solid #2196f3;
873
+ background: #f5f5f5;
874
+ }
875
+
876
+ .event-item.sent {
877
+ border-left-color: #4caf50;
878
+ background: #e8f5e9;
879
+ }
880
+
881
+ .event-item.error {
882
+ border-left-color: #f44336;
883
+ background: #ffebee;
884
+ }
885
+
886
+ .event-timestamp {
887
+ font-size: 0.85em;
888
+ color: #666;
889
+ margin-right: 10px;
890
+ }
891
+
892
+ .event-name {
893
+ font-weight: 600;
894
+ margin-right: 10px;
895
+ }
896
+
897
+ .event-type-badge {
898
+ display: inline-block;
899
+ padding: 2px 8px;
900
+ border-radius: 12px;
901
+ font-size: 0.75em;
902
+ font-weight: 600;
903
+ text-transform: uppercase;
904
+ }
905
+
906
+ .event-item.sent .event-type-badge {
907
+ background: #4caf50;
908
+ color: white;
909
+ }
910
+
911
+ .event-item.received .event-type-badge {
912
+ background: #2196f3;
913
+ color: white;
914
+ }
915
+
916
+ .event-item.error .event-type-badge {
917
+ background: #f44336;
918
+ color: white;
919
+ }
920
+
921
+ .event-data {
922
+ margin: 10px 0 0 0;
923
+ padding: 10px;
924
+ background: white;
925
+ border: 1px solid #ddd;
926
+ border-radius: 4px;
927
+ font-size: 0.85em;
928
+ overflow-x: auto;
929
+ white-space: pre-wrap;
930
+ word-wrap: break-word;
931
+ }
932
+ </style>
trigo-web/app/src/views/TrigoAgentTestView.vue CHANGED
@@ -218,9 +218,9 @@
218
 
219
  // Create and initialize inferencer
220
  inferencer = new OnnxInferencer({
221
- modelPath: "/onnx/GPT2CausalLM_ep0015_int8_seq2048_int8.onnx",
222
- vocabSize: 259,
223
- seqLen: 2048
224
  });
225
 
226
  await inferencer.initialize();
 
218
 
219
  // Create and initialize inferencer
220
  inferencer = new OnnxInferencer({
221
+ modelPath: import.meta.env.VITE_ONNX_TREE_MODEL,
222
+ vocabSize: 128,
223
+ seqLen: 256
224
  });
225
 
226
  await inferencer.initialize();
trigo-web/app/src/views/TrigoTreeTestView.vue CHANGED
@@ -158,7 +158,7 @@
158
  >
159
  <span class="move-notation">{{ move.notation }}</span>
160
  <span class="move-positions"
161
- >parent={{ move.parentPos }}, leaf={{ move.leafPos }}</span
162
  >
163
  </div>
164
  </div>
@@ -264,7 +264,7 @@
264
  import type { Move } from "../../../inc/trigo/types";
265
 
266
  // Configuration
267
- const modelPath = "/onnx/GPT2CausalLM_ep0015_evaluation.onnx";
268
  const vocabSize = 259;
269
 
270
  // State
@@ -285,7 +285,8 @@
285
  const treeVisualization = ref<{
286
  evaluatedIds: number[];
287
  mask: number[];
288
- moveData: Array<{ notation: string; parentPos: number; leafPos: number }>;
 
289
  } | null>(null);
290
 
291
  // Computed properties
@@ -329,6 +330,16 @@
329
  // Compute sum of exp scores
330
  const sumExp = expScores.reduce((sum, exp) => sum + exp, 0);
331
 
 
 
 
 
 
 
 
 
 
 
332
  // Return moves with normalized probabilities
333
  return scoredMoves.value.map((move, i) => ({
334
  ...move,
@@ -416,9 +427,9 @@
416
  treeVisualization.value = {
417
  evaluatedIds: treeStructure.evaluatedIds,
418
  mask: treeStructure.mask,
 
419
  moveData: treeStructure.moveData.map((m) => ({
420
  notation: m.notation,
421
- parentPos: m.parentPos,
422
  leafPos: m.leafPos
423
  }))
424
  };
 
158
  >
159
  <span class="move-notation">{{ move.notation }}</span>
160
  <span class="move-positions"
161
+ >leaf={{ move.leafPos }}, parent={{ treeVisualization.parent[move.leafPos] }}</span
162
  >
163
  </div>
164
  </div>
 
264
  import type { Move } from "../../../inc/trigo/types";
265
 
266
  // Configuration
267
+ const modelPath = import.meta.env.VITE_ONNX_TREE_MODEL;
268
  const vocabSize = 259;
269
 
270
  // State
 
285
  const treeVisualization = ref<{
286
  evaluatedIds: number[];
287
  mask: number[];
288
+ parent: Array<number | null>;
289
+ moveData: Array<{ notation: string; leafPos: number }>;
290
  } | null>(null);
291
 
292
  // Computed properties
 
330
  // Compute sum of exp scores
331
  const sumExp = expScores.reduce((sum, exp) => sum + exp, 0);
332
 
333
+ // Handle edge case where all probabilities underflow to 0
334
+ if (sumExp === 0 || !isFinite(sumExp)) {
335
+ console.warn("Probability sum is 0 or non-finite, using uniform distribution");
336
+ const uniformProb = 1.0 / scoredMoves.value.length;
337
+ return scoredMoves.value.map((move) => ({
338
+ ...move,
339
+ probability: uniformProb
340
+ }));
341
+ }
342
+
343
  // Return moves with normalized probabilities
344
  return scoredMoves.value.map((move, i) => ({
345
  ...move,
 
427
  treeVisualization.value = {
428
  evaluatedIds: treeStructure.evaluatedIds,
429
  mask: treeStructure.mask,
430
+ parent: treeStructure.parent,
431
  moveData: treeStructure.moveData.map((m) => ({
432
  notation: m.notation,
 
433
  leafPos: m.leafPos
434
  }))
435
  };
trigo-web/app/src/views/TrigoView.vue CHANGED
@@ -41,14 +41,43 @@
41
  <div v-else-if="gameMode === 'vs-people'" class="view-status people-mode">
42
  <div class="room-info">
43
  <span class="room-label">Room:</span>
44
- <span class="room-code">ABC123</span>
 
 
 
 
 
 
 
 
45
  </div>
 
46
  <div class="players-info">
47
- <span class="player-name">You (Black)</span>
 
 
 
 
 
 
 
 
48
  <span class="vs-divider">vs</span>
49
- <span class="player-name">Waiting...</span>
 
 
 
 
 
 
 
 
 
50
  </div>
51
- <span class="connection-status connected">🟢 Connected</span>
 
 
 
52
  </div>
53
 
54
  <!-- Library Mode: Game Info & Controls -->
@@ -62,6 +91,60 @@
62
  <button class="btn-library btn-export" title="Export TGN">📤 Export</button>
63
  </div>
64
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  </div>
66
 
67
  <div class="view-body">
@@ -213,36 +296,6 @@
213
  </div>
214
  </div>
215
  </div>
216
-
217
- <!-- Game Settings -->
218
- <div class="panel-section settings-section">
219
- <h3 class="section-title">Settings</h3>
220
- <div class="settings-content">
221
- <div class="setting-item">
222
- <label for="board-shape">
223
- Board Shape:
224
- <span
225
- v-if="isBoardShapeDirty"
226
- class="dirty-indicator"
227
- title="Board shape will change on next game"
228
- >*</span
229
- >
230
- </label>
231
- <select id="board-shape" v-model="selectedBoardShape">
232
- <option value="3*3*3">3×3×3</option>
233
- <option value="5*5*5">5×5×5</option>
234
- <option value="7*7*7">7×7×7</option>
235
- <option value="9*9*1">9×9×1 (2D)</option>
236
- <option value="13*13*1">13×13×1 (2D)</option>
237
- <option value="19*19*1">19×19×1 (2D)</option>
238
- <option value="9*9*2">9×9×2</option>
239
- </select>
240
- </div>
241
- <button class="btn btn-primary btn-new-game" @click="newGame">
242
- {{ gameStarted ? "Reset Game" : "Start Game" }}
243
- </button>
244
- </div>
245
- </div>
246
  </div>
247
  </div>
248
 
@@ -281,7 +334,11 @@
281
  import { useRoute } from "vue-router";
282
  import { TrigoViewport } from "@/services/trigoViewport";
283
  import { useGameStore } from "@/stores/gameStore";
 
284
  import { useTrigoAgent } from "@/composables/useTrigoAgent";
 
 
 
285
  import { storeToRefs } from "pinia";
286
  import type { BoardShape } from "../../../inc/trigo";
287
  import { Stone, validateMove, StoneType, validateTGN } from "../../../inc/trigo";
@@ -293,6 +350,31 @@
293
  const route = useRoute();
294
  const gameMode = computed(() => (route.meta.mode as string) || "single");
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  // Helper functions for board shape parsing
297
  const parseBoardShape = (shapeStr: string): BoardShape => {
298
  const parts = shapeStr
@@ -312,21 +394,6 @@
312
  return encodeAb0yz([move.x, move.y, move.z], [shape.x, shape.y, shape.z]);
313
  };
314
 
315
- // Use game store
316
- const gameStore = useGameStore();
317
- const {
318
- currentPlayer,
319
- moveHistory,
320
- currentMoveIndex,
321
- capturedStones,
322
- gameStatus,
323
- boardShape,
324
- moveCount,
325
- isGameActive,
326
- passCount
327
- } = storeToRefs(gameStore);
328
-
329
-
330
  // AI Agent (for VS AI mode)
331
  const aiAgent = useTrigoAgent();
332
  const { isReady: aiReady, isThinking: aiThinking, error: aiError, lastMoveTime } = aiAgent;
@@ -340,6 +407,19 @@
340
  const aiPlayerColor = ref<"black" | "white">(loadAIColor());
341
  const humanPlayerColor = computed(() => (aiPlayerColor.value === "white" ? "black" : "white"));
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  // Local state
344
  const hoveredPosition = ref<string | null>(null);
345
  const blackScore = ref(0);
@@ -497,29 +577,39 @@
497
 
498
  // Apply AI move if valid
499
  if (aiMove) {
500
- console.log(`[TrigoView] AI suggests move at (${aiMove.x}, ${aiMove.y}, ${aiMove.z})`);
501
-
502
- // Make move in store
503
- const result = gameStore.makeMove(aiMove.x, aiMove.y, aiMove.z);
504
-
505
- if (result.success && viewport) {
506
- // Add AI stone to viewport
507
- const stoneColor = gameStore.opponentPlayer;
508
- viewport.addStone(aiMove.x, aiMove.y, aiMove.z, stoneColor);
509
-
510
- // Remove captured stones
511
- if (result.capturedPositions && result.capturedPositions.length > 0) {
512
- result.capturedPositions.forEach((pos: any) => {
513
- viewport.removeStone(pos.x, pos.y, pos.z);
514
- });
515
- console.log(`[TrigoView] AI captured ${result.capturedPositions.length} stone(s)`);
516
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
 
518
- console.log(`[TrigoView] AI move applied successfully (${lastMoveTime.value.toFixed(0)}ms)`);
 
519
  }
520
  } else {
521
- console.log("[TrigoView] AI chose to pass");
522
- // Handle AI pass if needed
523
  }
524
  } catch (err) {
525
  console.error("[TrigoView] Failed to generate AI move:", err);
@@ -527,11 +617,39 @@
527
  };
528
 
529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  // Handle stone placement
531
  const handleStoneClick = async (x: number, y: number, z: number) => {
532
  if (!gameStarted.value) return;
533
 
534
- // Make move in store
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  const result = gameStore.makeMove(x, y, z);
536
 
537
  if (result.success && viewport) {
@@ -656,7 +774,40 @@
656
  }
657
  };
658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
  const pass = () => {
 
 
 
 
 
 
 
 
 
 
 
 
660
  const previousPlayer = currentPlayer.value;
661
  const success = gameStore.pass();
662
 
@@ -840,6 +991,265 @@
840
  }
841
  };
842
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
843
  // Watch for current player changes to update viewport preview
844
  watch(currentPlayer, (newPlayer) => {
845
  if (viewport) {
@@ -862,6 +1272,20 @@
862
  });
863
  });
864
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
865
  // Watch TGN modal to populate editable content when opened
866
  watch(showTGNModal, (isVisible) => {
867
  if (isVisible) {
@@ -876,8 +1300,12 @@
876
  onMounted(() => {
877
  console.log("TrigoDemo component mounted");
878
 
879
- // Try to restore game state from session storage
880
- const restoredFromStorage = gameStore.loadFromSessionStorage();
 
 
 
 
881
 
882
  // If not restored from storage, initialize new game
883
  if (!restoredFromStorage) {
@@ -947,6 +1375,171 @@
947
  });
948
  }
949
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
950
  // Add keyboard shortcuts
951
  window.addEventListener("keydown", handleKeyPress);
952
  });
@@ -978,6 +1571,22 @@
978
  // Remove keyboard shortcuts
979
  window.removeEventListener("keydown", handleKeyPress);
980
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
981
  // Disconnect ResizeObserver
982
  if (resizeObserver) {
983
  resizeObserver.disconnect();
@@ -1081,7 +1690,7 @@
1081
 
1082
  .view-header {
1083
  display: flex;
1084
- justify-content: center;
1085
  align-items: center;
1086
  padding: 1rem 2rem;
1087
  background: linear-gradient(135deg, #505050 0%, #454545 100%);
@@ -1225,6 +1834,21 @@
1225
  font-size: 1.1rem;
1226
  }
1227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1228
  .players-info {
1229
  display: flex;
1230
  align-items: center;
@@ -1234,11 +1858,24 @@
1234
  border-radius: 8px;
1235
  }
1236
 
 
 
 
 
 
 
 
 
1237
  .player-name {
1238
  color: #e0e0e0;
1239
  font-weight: 600;
1240
  }
1241
 
 
 
 
 
 
1242
  .vs-divider {
1243
  color: #606060;
1244
  font-weight: 500;
@@ -1325,6 +1962,68 @@
1325
  }
1326
  }
1327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1328
  .view-body {
1329
  display: flex;
1330
  flex: 1;
@@ -1690,68 +2389,6 @@
1690
  }
1691
  }
1692
 
1693
- .settings-section {
1694
- .settings-content {
1695
- display: flex;
1696
- flex-direction: column;
1697
- gap: 1rem;
1698
-
1699
- .setting-item {
1700
- display: flex;
1701
- align-items: center;
1702
- justify-content: space-between;
1703
-
1704
- label {
1705
- font-weight: 600;
1706
- color: #a0a0a0;
1707
- display: flex;
1708
- align-items: center;
1709
- gap: 0.3rem;
1710
-
1711
- .dirty-indicator {
1712
- color: #e94560;
1713
- font-size: 1.2rem;
1714
- font-weight: 700;
1715
- animation: pulse 2s ease-in-out infinite;
1716
- }
1717
- }
1718
-
1719
- select {
1720
- padding: 0.5rem;
1721
- border: 2px solid #505050;
1722
- border-radius: 8px;
1723
- background-color: #484848;
1724
- color: #e0e0e0;
1725
- cursor: pointer;
1726
- font-weight: 600;
1727
-
1728
- &:disabled {
1729
- opacity: 0.5;
1730
- cursor: not-allowed;
1731
- }
1732
- }
1733
- }
1734
-
1735
- .btn-new-game {
1736
- width: 100%;
1737
- padding: 0.75rem;
1738
- background-color: #e94560;
1739
- color: #fff;
1740
- border: none;
1741
- border-radius: 8px;
1742
- font-weight: 700;
1743
- cursor: pointer;
1744
- transition: all 0.3s ease;
1745
- text-transform: uppercase;
1746
-
1747
- &:hover {
1748
- background-color: #f95670;
1749
- transform: translateY(-2px);
1750
- box-shadow: 0 4px 12px rgba(233, 69, 96, 0.4);
1751
- }
1752
- }
1753
- }
1754
- }
1755
  }
1756
  }
1757
  }
 
41
  <div v-else-if="gameMode === 'vs-people'" class="view-status people-mode">
42
  <div class="room-info">
43
  <span class="room-label">Room:</span>
44
+ <span class="room-code">{{ playerStore.roomId || "---" }}</span>
45
+ <button
46
+ v-if="playerStore.roomId"
47
+ class="btn-copy-room"
48
+ @click="copyRoomCode"
49
+ title="Copy room code"
50
+ >
51
+ 📋
52
+ </button>
53
  </div>
54
+
55
  <div class="players-info">
56
+ <div class="player-display" :class="{ 'on-turn': isLocalPlayerTurn }">
57
+ <inline-nickname-editor
58
+ :nickname="playerStore.nickname"
59
+ :editable="true"
60
+ :player-color="playerStore.playerColor"
61
+ @update="updateLocalNickname"
62
+ />
63
+ </div>
64
+
65
  <span class="vs-divider">vs</span>
66
+
67
+ <div class="player-display" :class="{ 'on-turn': !isLocalPlayerTurn }">
68
+ <inline-nickname-editor
69
+ v-if="playerStore.opponentNickname"
70
+ :nickname="playerStore.opponentNickname"
71
+ :editable="false"
72
+ :player-color="opponentPlayerColor"
73
+ />
74
+ <span v-else class="waiting-text">Waiting...</span>
75
+ </div>
76
  </div>
77
+
78
+ <span class="connection-status" :class="connectionStatusClass">
79
+ {{ connectionStatusIcon }} {{ connectionStatusText }}
80
+ </span>
81
  </div>
82
 
83
  <!-- Library Mode: Game Info & Controls -->
 
91
  <button class="btn-library btn-export" title="Export TGN">📤 Export</button>
92
  </div>
93
  </div>
94
+
95
+ <!-- Header Controls (right side) -->
96
+ <div class="header-controls" v-if="gameMode === 'single'">
97
+ <select v-model="selectedBoardShape" class="board-shape-select">
98
+ <option value="3x3x3">3×3×3</option>
99
+ <option value="5x5x5">5×5×5</option>
100
+ <option value="7x7x7">7×7×7</option>
101
+ <option value="9x9x1">9×9×1 (2D)</option>
102
+ <option value="9x9x9">9×9×9</option>
103
+ <option value="5x5x1">5×5×1 (2D)</option>
104
+ <option value="7x7x1">7×7×1 (2D)</option>
105
+ </select>
106
+ <button class="btn-reset" @click="newGame">
107
+ {{ gameStarted ? "Reset" : "Start" }}
108
+ </button>
109
+ </div>
110
+
111
+ <div class="header-controls" v-else-if="gameMode === 'vs-ai'">
112
+ <select v-model="selectedBoardShape" class="board-shape-select">
113
+ <option value="3x3x3">3×3×3</option>
114
+ <option value="5x5x5">5×5×5</option>
115
+ <option value="7x7x7">7×7×7</option>
116
+ <option value="9x9x1">9×9×1 (2D)</option>
117
+ <option value="9x9x9">9×9×9</option>
118
+ <option value="5x5x1">5×5×1 (2D)</option>
119
+ <option value="7x7x1">7×7×1 (2D)</option>
120
+ </select>
121
+ <button class="btn-reset" @click="newGame">
122
+ {{ gameStarted ? "Reset" : "Start" }}
123
+ </button>
124
+ </div>
125
+
126
+ <div class="header-controls" v-else-if="gameMode === 'vs-people'">
127
+ <select
128
+ v-model="preferredColor"
129
+ class="color-preference-select"
130
+ :disabled="gameStarted || connectionStatus === 'in-room'"
131
+ >
132
+ <option value="black">Play as Black</option>
133
+ <option value="white">Play as White</option>
134
+ </select>
135
+
136
+ <select v-model="selectedBoardShape" class="board-shape-select">
137
+ <option value="3x3x3">3×3×3</option>
138
+ <option value="5x5x5">5×5×5</option>
139
+ <option value="7x7x7">7×7×7</option>
140
+ <option value="9x9x1">9×9×1 (2D)</option>
141
+ <option value="9x9x9">9×9×9</option>
142
+ <option value="5x5x1">5×5×1 (2D)</option>
143
+ <option value="7x7x1">7×7×1 (2D)</option>
144
+ </select>
145
+
146
+ <button class="btn-reset" @click="resetMultiplayerGame">Reset</button>
147
+ </div>
148
  </div>
149
 
150
  <div class="view-body">
 
296
  </div>
297
  </div>
298
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  </div>
300
  </div>
301
 
 
334
  import { useRoute } from "vue-router";
335
  import { TrigoViewport } from "@/services/trigoViewport";
336
  import { useGameStore } from "@/stores/gameStore";
337
+ import { usePlayerStore } from "@/stores/playerStore";
338
  import { useTrigoAgent } from "@/composables/useTrigoAgent";
339
+ import { useSocket } from "@/composables/useSocket";
340
+ import { useRoomHash } from "@/composables/useRoomHash";
341
+ import InlineNicknameEditor from "@/components/InlineNicknameEditor.vue";
342
  import { storeToRefs } from "pinia";
343
  import type { BoardShape } from "../../../inc/trigo";
344
  import { Stone, validateMove, StoneType, validateTGN } from "../../../inc/trigo";
 
350
  const route = useRoute();
351
  const gameMode = computed(() => (route.meta.mode as string) || "single");
352
 
353
+ // Game store
354
+ const gameStore = useGameStore();
355
+ const {
356
+ game,
357
+ currentPlayer,
358
+ moveHistory,
359
+ currentMoveIndex,
360
+ capturedStones,
361
+ gameStatus,
362
+ boardShape,
363
+ moveCount,
364
+ isGameActive,
365
+ passCount
366
+ } = storeToRefs(gameStore);
367
+
368
+ // Player store (for multiplayer)
369
+ const playerStore = usePlayerStore();
370
+
371
+ // Socket.io (for multiplayer)
372
+ const socketApi = useSocket();
373
+
374
+ // Room hash management (for VS People mode)
375
+ const { getRoomIdFromHash, updateHash, clearHash, isValidRoomId } = useRoomHash();
376
+ const isJoiningRoom = ref(false);
377
+
378
  // Helper functions for board shape parsing
379
  const parseBoardShape = (shapeStr: string): BoardShape => {
380
  const parts = shapeStr
 
394
  return encodeAb0yz([move.x, move.y, move.z], [shape.x, shape.y, shape.z]);
395
  };
396
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  // AI Agent (for VS AI mode)
398
  const aiAgent = useTrigoAgent();
399
  const { isReady: aiReady, isThinking: aiThinking, error: aiError, lastMoveTime } = aiAgent;
 
407
  const aiPlayerColor = ref<"black" | "white">(loadAIColor());
408
  const humanPlayerColor = computed(() => (aiPlayerColor.value === "white" ? "black" : "white"));
409
 
410
+ // VS People color preference with storage persistence
411
+ const loadPreferredColor = (): "black" | "white" => {
412
+ const stored = storage.getString("vs-people-color-preference");
413
+ return stored === "black" || stored === "white" ? stored : "black";
414
+ };
415
+
416
+ const preferredColor = ref<"black" | "white">(loadPreferredColor());
417
+
418
+ // Watch for color preference changes and persist to storage
419
+ watch(preferredColor, (newColor) => {
420
+ storage.setString("vs-people-color-preference", newColor);
421
+ });
422
+
423
  // Local state
424
  const hoveredPosition = ref<string | null>(null);
425
  const blackScore = ref(0);
 
577
 
578
  // Apply AI move if valid
579
  if (aiMove) {
580
+ // Check if AI chose to pass
581
+ if (aiMove.isPass) {
582
+ console.log("[TrigoView] AI chose to pass");
583
+ const result = gameStore.pass();
584
+ if (result.success) {
585
+ console.log(`[TrigoView] AI pass applied successfully (${lastMoveTime.value.toFixed(0)}ms)`);
586
+ } else {
587
+ console.error("[TrigoView] Failed to apply AI pass");
 
 
 
 
 
 
 
 
588
  }
589
+ } else {
590
+ console.log(`[TrigoView] AI suggests move at (${aiMove.x}, ${aiMove.y}, ${aiMove.z})`);
591
+
592
+ // Make move in store
593
+ const result = gameStore.makeMove(aiMove.x, aiMove.y, aiMove.z);
594
+
595
+ if (result.success && viewport) {
596
+ // Add AI stone to viewport
597
+ const stoneColor = gameStore.opponentPlayer;
598
+ viewport.addStone(aiMove.x, aiMove.y, aiMove.z, stoneColor);
599
+
600
+ // Remove captured stones
601
+ if (result.capturedPositions && result.capturedPositions.length > 0) {
602
+ result.capturedPositions.forEach((pos: any) => {
603
+ viewport.removeStone(pos.x, pos.y, pos.z);
604
+ });
605
+ console.log(`[TrigoView] AI captured ${result.capturedPositions.length} stone(s)`);
606
+ }
607
 
608
+ console.log(`[TrigoView] AI move applied successfully (${lastMoveTime.value.toFixed(0)}ms)`);
609
+ }
610
  }
611
  } else {
612
+ console.log("[TrigoView] AI returned no move");
 
613
  }
614
  } catch (err) {
615
  console.error("[TrigoView] Failed to generate AI move:", err);
 
617
  };
618
 
619
 
620
+ // Sync viewport with current game state (for undo/redo/reset)
621
+ const syncViewportWithGame = () => {
622
+ if (!viewport) return;
623
+
624
+ // Get all stones from game store and add to viewport
625
+ const stones = gameStore.getAllStones();
626
+ for (const stone of stones) {
627
+ viewport.addStone(stone.x, stone.y, stone.z, stone.color);
628
+ }
629
+
630
+ console.log(`[TrigoView] Synced viewport with ${stones.length} stones`);
631
+ };
632
+
633
+
634
  // Handle stone placement
635
  const handleStoneClick = async (x: number, y: number, z: number) => {
636
  if (!gameStarted.value) return;
637
 
638
+ // VS People mode: validate turn and emit socket event
639
+ if (gameMode.value === "vs-people") {
640
+ // Check if it's the local player's turn
641
+ if (!isLocalPlayerTurn.value) {
642
+ console.log("[TrigoView] Not your turn, ignoring click");
643
+ return;
644
+ }
645
+
646
+ // Emit move to server (server will validate and broadcast)
647
+ console.log(`[TrigoView] Emitting makeMove: (${x}, ${y}, ${z})`);
648
+ socketApi.makeMove(x, y, z);
649
+ return;
650
+ }
651
+
652
+ // Single player and VS AI mode: make move locally
653
  const result = gameStore.makeMove(x, y, z);
654
 
655
  if (result.success && viewport) {
 
774
  }
775
  };
776
 
777
+ // Reset game for multiplayer (VS People mode)
778
+ const resetMultiplayerGame = () => {
779
+ if (!confirm("Reset the game? This will clear all moves.")) return;
780
+
781
+ const shape = parseBoardShape(selectedBoardShape.value);
782
+ const swapColors = playerStore.playerColor !== preferredColor.value;
783
+
784
+ socketApi.socket.emit("resetGame", {
785
+ boardShape: shape,
786
+ swapColors: swapColors
787
+ }, (response: any) => {
788
+ if (response.success) {
789
+ console.log("Multiplayer game reset successfully");
790
+ // Local reset is handled by gameReset event listener
791
+ } else {
792
+ console.error("Reset failed:", response.error);
793
+ alert(`Failed to reset game: ${response.error || "Unknown error"}`);
794
+ }
795
+ });
796
+ };
797
+
798
  const pass = () => {
799
+ // VS People mode: validate turn and emit socket event
800
+ if (gameMode.value === "vs-people") {
801
+ if (!isLocalPlayerTurn.value) {
802
+ console.log("[TrigoView] Not your turn, cannot pass");
803
+ return;
804
+ }
805
+ console.log("[TrigoView] Emitting pass");
806
+ socketApi.pass();
807
+ return;
808
+ }
809
+
810
+ // Single player and VS AI mode: pass locally
811
  const previousPlayer = currentPlayer.value;
812
  const success = gameStore.pass();
813
 
 
991
  }
992
  };
993
 
994
+ // ===== VS People Mode: Nickname Management =====
995
+
996
+ // Computed properties for multiplayer
997
+ const opponentPlayerColor = computed(() => {
998
+ if (!playerStore.playerColor) return null;
999
+ return playerStore.playerColor === "black" ? "white" : "black";
1000
+ });
1001
+
1002
+ const isLocalPlayerTurn = computed(() => {
1003
+ return currentPlayer.value === playerStore.playerColor;
1004
+ });
1005
+
1006
+ const connectionStatusClass = computed(() => {
1007
+ return playerStore.connectionStatus === "in-room" ? "connected" : "disconnected";
1008
+ });
1009
+
1010
+ const connectionStatusText = computed(() => {
1011
+ if (playerStore.connectionStatus === "in-room") return "Connected";
1012
+ if (playerStore.connectionStatus === "connected") return "Joining room...";
1013
+ return "Disconnected";
1014
+ });
1015
+
1016
+ const connectionStatusIcon = computed(() => {
1017
+ if (playerStore.connectionStatus === "in-room") return "🟢";
1018
+ if (playerStore.connectionStatus === "connected") return "🟡"; // Yellow while joining
1019
+ return "🔴";
1020
+ });
1021
+
1022
+ // Update local player nickname
1023
+ const updateLocalNickname = (newNickname: string) => {
1024
+ const success = playerStore.setNickname(newNickname);
1025
+
1026
+ if (!success) {
1027
+ console.error("Failed to update nickname");
1028
+ return;
1029
+ }
1030
+
1031
+ // If in a room, broadcast nickname change to opponents
1032
+ if (playerStore.isInRoom) {
1033
+ socketApi.changeNickname(newNickname, (response: any) => {
1034
+ if (!response.success) {
1035
+ console.error("Failed to broadcast nickname change:", response.error);
1036
+ alert(`Failed to change nickname: ${response.error}`);
1037
+ }
1038
+ });
1039
+ }
1040
+ };
1041
+
1042
+ // Copy room code to clipboard
1043
+ const copyRoomCode = () => {
1044
+ if (!playerStore.roomId) return;
1045
+
1046
+ navigator.clipboard
1047
+ .writeText(playerStore.roomId)
1048
+ .then(() => alert("Room code copied to clipboard!"))
1049
+ .catch((err) => console.error("Failed to copy room code:", err));
1050
+ };
1051
+
1052
+
1053
+ // ===== Room Management Functions (VS People Mode) =====
1054
+
1055
+ /**
1056
+ * Wait for socket connection with timeout
1057
+ * Prevents race condition where hash is read before socket connects
1058
+ */
1059
+ async function waitForSocketConnection(timeout = 5000): Promise<void> {
1060
+ return new Promise((resolve, reject) => {
1061
+ if (socketApi.socket.connected) {
1062
+ resolve();
1063
+ return;
1064
+ }
1065
+
1066
+ const checkInterval = setInterval(() => {
1067
+ if (socketApi.socket.connected) {
1068
+ clearInterval(checkInterval);
1069
+ clearTimeout(timeoutHandle);
1070
+ resolve();
1071
+ }
1072
+ }, 100);
1073
+
1074
+ const timeoutHandle = setTimeout(() => {
1075
+ clearInterval(checkInterval);
1076
+ reject(new Error("Socket connection timeout"));
1077
+ }, timeout);
1078
+ });
1079
+ }
1080
+
1081
+
1082
+ /**
1083
+ * Initialize multiplayer room based on URL hash
1084
+ * - No hash: create new room
1085
+ * - Valid hash: join existing room
1086
+ * - Invalid hash: redirect to create new room
1087
+ */
1088
+ async function initializeMultiplayerRoom() {
1089
+ console.log("[TrigoView] ========== INITIALIZE MULTIPLAYER ROOM ==========");
1090
+ console.log("[TrigoView] isJoiningRoom:", isJoiningRoom.value);
1091
+ console.log("[TrigoView] Socket connected:", socketApi.socket.connected);
1092
+ console.log("[TrigoView] Socket ID:", socketApi.socket.id);
1093
+
1094
+ if (isJoiningRoom.value) {
1095
+ console.log("[TrigoView] Already joining, skipping");
1096
+ return; // Prevent duplicate joins
1097
+ }
1098
+
1099
+ const hashRoomId = getRoomIdFromHash();
1100
+ console.log("[TrigoView] hashRoomId from URL:", hashRoomId);
1101
+
1102
+ if (!hashRoomId) {
1103
+ // No hash: create new room
1104
+ console.log("[TrigoView] No hash found, creating new room");
1105
+ await createAndJoinRoom();
1106
+ } else if (isValidRoomId(hashRoomId)) {
1107
+ // Valid hash: join existing room
1108
+ console.log("[TrigoView] Valid hash found, joining room:", hashRoomId);
1109
+ await joinRoomByHash(hashRoomId);
1110
+ } else {
1111
+ // Invalid hash: redirect to create new room
1112
+ console.warn(`[TrigoView] Invalid room ID in hash: ${hashRoomId}`);
1113
+ clearHash();
1114
+ await createAndJoinRoom();
1115
+ }
1116
+ }
1117
+
1118
+
1119
+ /**
1120
+ * Create a new room and join it
1121
+ */
1122
+ async function createAndJoinRoom() {
1123
+ console.log("[TrigoView] createAndJoinRoom called, isJoiningRoom:", isJoiningRoom.value);
1124
+ if (isJoiningRoom.value) {
1125
+ console.log("[TrigoView] Already joining, returning");
1126
+ return;
1127
+ }
1128
+ isJoiningRoom.value = true;
1129
+ console.log("[TrigoView] Set isJoiningRoom to true");
1130
+
1131
+ try {
1132
+ console.log("[TrigoView] Waiting for socket connection...");
1133
+ await waitForSocketConnection();
1134
+ console.log("[TrigoView] Socket connection ready, calling joinRoom");
1135
+
1136
+ socketApi.joinRoom({ nickname: playerStore.nickname }, (response: any) => {
1137
+ console.log("[TrigoView] joinRoom callback received:", response);
1138
+ isJoiningRoom.value = false;
1139
+
1140
+ if (response.success !== false && response.roomId) {
1141
+ playerStore.joinRoom(response.roomId, response.playerColor);
1142
+ updateHash(response.roomId);
1143
+ console.log(`[TrigoView] Room created: ${response.roomId}`);
1144
+ } else {
1145
+ console.error("[TrigoView] Failed to create room:", response.error);
1146
+ alert("Failed to create room. Please try again.");
1147
+ }
1148
+ });
1149
+ } catch (err) {
1150
+ isJoiningRoom.value = false;
1151
+ console.error("[TrigoView] Socket connection failed:", err);
1152
+ alert("Connection failed. Please check your internet and try again.");
1153
+ }
1154
+ }
1155
+
1156
+
1157
+ /**
1158
+ * Join an existing room by room ID from hash
1159
+ */
1160
+ async function joinRoomByHash(roomId: string) {
1161
+ if (isJoiningRoom.value) return;
1162
+ isJoiningRoom.value = true;
1163
+
1164
+ try {
1165
+ await waitForSocketConnection();
1166
+
1167
+ socketApi.joinRoom({ roomId, nickname: playerStore.nickname }, (response: any) => {
1168
+ isJoiningRoom.value = false;
1169
+
1170
+ if (response.success !== false && response.roomId) {
1171
+ playerStore.joinRoom(response.roomId, response.playerColor);
1172
+ console.log(`[TrigoView] Room joined: ${response.roomId}`);
1173
+
1174
+ // Extract opponent from players list
1175
+ if (response.players && socketApi.socket.id) {
1176
+ for (const [playerId, player] of Object.entries(response.players) as [string, any][]) {
1177
+ if (playerId !== socketApi.socket.id) {
1178
+ playerStore.setOpponentNickname(player.nickname);
1179
+ console.log(`[TrigoView] Opponent found: ${player.nickname}`);
1180
+ break;
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ // Start game if both players are present
1186
+ const playerCount = response.players ? Object.keys(response.players).length : 0;
1187
+ if (playerCount >= 2 && !gameStarted.value) {
1188
+ gameStore.startGame();
1189
+ if (viewport) {
1190
+ viewport.setGameActive(true);
1191
+ }
1192
+ console.log("[TrigoView] Game started - both players present");
1193
+ }
1194
+ } else {
1195
+ console.warn(`[TrigoView] Failed to join room:`, response.error);
1196
+ handleJoinFailure(response.error || "Failed to join room");
1197
+ }
1198
+ });
1199
+ } catch (err) {
1200
+ isJoiningRoom.value = false;
1201
+ console.error("[TrigoView] Socket connection failed:", err);
1202
+ alert("Connection failed. Please check your internet and try again.");
1203
+ }
1204
+ }
1205
+
1206
+
1207
+ /**
1208
+ * Handle room join failure
1209
+ * Clears invalid hash and creates new room
1210
+ */
1211
+ function handleJoinFailure(error: string) {
1212
+ clearHash();
1213
+
1214
+ if (error.includes("not found")) {
1215
+ alert("Room not found. Creating a new room...");
1216
+ } else if (error.includes("full")) {
1217
+ alert("This room is full. Creating a new room...");
1218
+ } else {
1219
+ alert(`Failed to join room: ${error}. Creating a new room...`);
1220
+ }
1221
+
1222
+ createAndJoinRoom();
1223
+ }
1224
+
1225
+
1226
+ /**
1227
+ * Handle browser navigation (back/forward) and manual hash edits
1228
+ */
1229
+ function handleHashChange() {
1230
+ if (gameMode.value !== "vs-people") return;
1231
+
1232
+ const currentHash = getRoomIdFromHash();
1233
+ const currentRoom = playerStore.roomId;
1234
+
1235
+ if (currentHash !== currentRoom) {
1236
+ if (currentHash && isValidRoomId(currentHash)) {
1237
+ // User navigated to different room hash
1238
+ if (currentRoom) socketApi.leaveRoom();
1239
+ joinRoomByHash(currentHash);
1240
+ } else {
1241
+ // Invalid hash or removed hash
1242
+ if (currentRoom) {
1243
+ updateHash(currentRoom); // Restore correct hash
1244
+ } else {
1245
+ createAndJoinRoom(); // Create new room
1246
+ }
1247
+ }
1248
+ }
1249
+ }
1250
+
1251
+ // ===== Watchers =====
1252
+
1253
  // Watch for current player changes to update viewport preview
1254
  watch(currentPlayer, (newPlayer) => {
1255
  if (viewport) {
 
1272
  });
1273
  });
1274
 
1275
+ // Watch playerStore.roomId to sync URL hash (VS People mode)
1276
+ watch(
1277
+ () => playerStore.roomId,
1278
+ (newRoomId) => {
1279
+ if (gameMode.value !== "vs-people") return;
1280
+
1281
+ if (newRoomId) {
1282
+ updateHash(newRoomId);
1283
+ } else {
1284
+ clearHash();
1285
+ }
1286
+ }
1287
+ );
1288
+
1289
  // Watch TGN modal to populate editable content when opened
1290
  watch(showTGNModal, (isVisible) => {
1291
  if (isVisible) {
 
1300
  onMounted(() => {
1301
  console.log("TrigoDemo component mounted");
1302
 
1303
+ // In VS People mode, always start fresh (server maintains authoritative state)
1304
+ // In other modes, try to restore from session storage
1305
+ let restoredFromStorage = false;
1306
+ if (gameMode.value !== "vs-people") {
1307
+ restoredFromStorage = gameStore.loadFromSessionStorage();
1308
+ }
1309
 
1310
  // If not restored from storage, initialize new game
1311
  if (!restoredFromStorage) {
 
1375
  });
1376
  }
1377
 
1378
+ // Setup socket listeners for VS People mode
1379
+ if (gameMode.value === "vs-people") {
1380
+ console.log("[TrigoView] Setting up socket listeners for VS People mode...");
1381
+
1382
+ // Listen for nickname changes
1383
+ socketApi.onNicknameChanged((data) => {
1384
+ console.log(
1385
+ `[TrigoView] Player ${data.playerId} changed nickname: ${data.oldNickname} -> ${data.nickname}`
1386
+ );
1387
+
1388
+ // If it's not us, update opponent nickname
1389
+ if (data.playerId !== socketApi.socket.id) {
1390
+ playerStore.setOpponentNickname(data.nickname);
1391
+ }
1392
+ });
1393
+
1394
+ // Listen for game updates (moves, passes, undo, redo)
1395
+ socketApi.onGameUpdate((data) => {
1396
+ console.log("[TrigoView] Game update received:", data);
1397
+
1398
+ // Update current player from server
1399
+ if (data.currentPlayer) {
1400
+ gameStore.setCurrentPlayer(data.currentPlayer);
1401
+ }
1402
+
1403
+ // Handle different actions
1404
+ if (data.action === "move" && data.lastMove) {
1405
+ const { x, y, z } = data.lastMove;
1406
+ // Apply move to local game state
1407
+ const result = gameStore.makeMove(x, y, z);
1408
+
1409
+ if (result.success && viewport) {
1410
+ // Add stone to viewport
1411
+ const stoneColor = gameStore.opponentPlayer;
1412
+ viewport.addStone(x, y, z, stoneColor);
1413
+
1414
+ // Remove captured stones
1415
+ if (data.capturedPositions && data.capturedPositions.length > 0) {
1416
+ data.capturedPositions.forEach((pos: { x: number; y: number; z: number }) => {
1417
+ viewport.removeStone(pos.x, pos.y, pos.z);
1418
+ });
1419
+ console.log(`[TrigoView] Captured ${data.capturedPositions.length} stone(s)`);
1420
+ }
1421
+
1422
+ // Hide territory visualization
1423
+ viewport.hideDomainCubes();
1424
+ showTerritoryMode.value = false;
1425
+ }
1426
+ } else if (data.action === "pass") {
1427
+ gameStore.pass();
1428
+ console.log("[TrigoView] Pass received from server");
1429
+ } else if (data.action === "undo" || data.action === "redo") {
1430
+ // For undo/redo, reload game from TGN
1431
+ if (data.tgn && viewport) {
1432
+ gameStore.loadFromTGN(data.tgn);
1433
+ // Resync viewport with game state
1434
+ viewport.clearBoard();
1435
+ syncViewportWithGame();
1436
+ }
1437
+ }
1438
+ });
1439
+
1440
+ // Listen for player joined
1441
+ socketApi.onPlayerJoined((data) => {
1442
+ console.log("[TrigoView] Player joined:", data);
1443
+ playerStore.setOpponentNickname(data.nickname);
1444
+
1445
+ // Start game when second player joins
1446
+ if (!gameStarted.value) {
1447
+ gameStore.startGame();
1448
+ if (viewport) {
1449
+ viewport.setGameActive(true);
1450
+ }
1451
+ console.log("[TrigoView] Game started - opponent joined");
1452
+ }
1453
+ });
1454
+
1455
+ // Listen for player left
1456
+ socketApi.onPlayerLeft((data) => {
1457
+ console.log("[TrigoView] Player left:", data);
1458
+ playerStore.setOpponentNickname(null);
1459
+ });
1460
+
1461
+ // Listen for player disconnected
1462
+ socketApi.onPlayerDisconnected((data) => {
1463
+ console.log("[TrigoView] Player disconnected:", data);
1464
+ if (data.playerId !== socketApi.socket.id) {
1465
+ playerStore.setOpponentNickname(null);
1466
+ }
1467
+ });
1468
+
1469
+ // Listen for game ended
1470
+ socketApi.onGameEnded((data) => {
1471
+ console.log("[TrigoView] Game ended:", data);
1472
+ alert(`Game ended! Winner: ${data.winner || "None"}\nReason: ${data.reason}`);
1473
+ });
1474
+
1475
+ // Listen for game reset
1476
+ socketApi.onGameReset((data) => {
1477
+ console.log("[TrigoView] Game reset:", data);
1478
+ // Reload game state
1479
+ if (data.tgn) {
1480
+ gameStore.loadFromTGN(data.tgn);
1481
+ } else {
1482
+ gameStore.resetGame(boardShape.value);
1483
+ }
1484
+ if (viewport) {
1485
+ viewport.clearBoard();
1486
+ }
1487
+ // Update player colors if changed
1488
+ if (data.players && socketApi.socket.id) {
1489
+ const myPlayer = data.players[socketApi.socket.id];
1490
+ if (myPlayer) {
1491
+ playerStore.playerColor = myPlayer.color;
1492
+ }
1493
+ }
1494
+ });
1495
+
1496
+ // Listen for errors
1497
+ socketApi.onError((data) => {
1498
+ console.error("[TrigoView] Socket error:", data.message);
1499
+ });
1500
+
1501
+ // Set player ID when socket connects
1502
+ if (socketApi.socket.id) {
1503
+ playerStore.setPlayerId(socketApi.socket.id);
1504
+ }
1505
+
1506
+ // Listen for browser navigation (back/forward) and manual hash edits
1507
+ window.addEventListener("hashchange", handleHashChange);
1508
+
1509
+ // Handle socket connection and reconnection
1510
+ socketApi.socket.on("connect", () => {
1511
+ console.log("[TrigoView] Socket connected in VS People mode");
1512
+
1513
+ // Update player ID and connection status
1514
+ if (socketApi.socket.id) {
1515
+ playerStore.setPlayerId(socketApi.socket.id);
1516
+ }
1517
+
1518
+ // Initialize room based on URL hash (only if not already in a room)
1519
+ if (!playerStore.roomId && !isJoiningRoom.value) {
1520
+ console.log("[TrigoView] Initializing room after socket connection");
1521
+ initializeMultiplayerRoom();
1522
+ }
1523
+ });
1524
+
1525
+ // Handle socket disconnection
1526
+ socketApi.socket.on("disconnect", () => {
1527
+ console.log("[TrigoView] Socket disconnected in VS People mode");
1528
+ // Don't reset roomId - we want to rejoin on reconnect
1529
+ if (playerStore.roomId) {
1530
+ playerStore.connectionStatus = "connected"; // Still have room info, just disconnected
1531
+ } else {
1532
+ playerStore.disconnect();
1533
+ }
1534
+ });
1535
+
1536
+ // If socket is already connected, initialize room immediately
1537
+ if (socketApi.socket.connected) {
1538
+ console.log("[TrigoView] Socket already connected, initializing room");
1539
+ initializeMultiplayerRoom();
1540
+ }
1541
+ }
1542
+
1543
  // Add keyboard shortcuts
1544
  window.addEventListener("keydown", handleKeyPress);
1545
  });
 
1571
  // Remove keyboard shortcuts
1572
  window.removeEventListener("keydown", handleKeyPress);
1573
 
1574
+ // Cleanup socket listeners for VS People mode
1575
+ if (gameMode.value === "vs-people") {
1576
+ console.log("[TrigoView] Cleaning up socket listeners...");
1577
+ socketApi.offNicknameChanged();
1578
+ socketApi.offGameUpdate();
1579
+ socketApi.offPlayerJoined();
1580
+ socketApi.offPlayerLeft();
1581
+ socketApi.offPlayerDisconnected();
1582
+ socketApi.offGameEnded();
1583
+ socketApi.offGameReset();
1584
+ socketApi.offError();
1585
+
1586
+ // Remove hashchange listener
1587
+ window.removeEventListener("hashchange", handleHashChange);
1588
+ }
1589
+
1590
  // Disconnect ResizeObserver
1591
  if (resizeObserver) {
1592
  resizeObserver.disconnect();
 
1690
 
1691
  .view-header {
1692
  display: flex;
1693
+ justify-content: space-between;
1694
  align-items: center;
1695
  padding: 1rem 2rem;
1696
  background: linear-gradient(135deg, #505050 0%, #454545 100%);
 
1834
  font-size: 1.1rem;
1835
  }
1836
 
1837
+ .btn-copy-room {
1838
+ padding: 0.25rem 0.5rem;
1839
+ background-color: transparent;
1840
+ border: 1px solid #606060;
1841
+ border-radius: 4px;
1842
+ cursor: pointer;
1843
+ font-size: 0.9rem;
1844
+ transition: all 0.2s ease;
1845
+
1846
+ &:hover {
1847
+ background-color: #505050;
1848
+ border-color: #808080;
1849
+ }
1850
+ }
1851
+
1852
  .players-info {
1853
  display: flex;
1854
  align-items: center;
 
1858
  border-radius: 8px;
1859
  }
1860
 
1861
+ .player-display {
1862
+ transition: all 0.3s ease;
1863
+
1864
+ &.on-turn {
1865
+ filter: brightness(1.3);
1866
+ }
1867
+ }
1868
+
1869
  .player-name {
1870
  color: #e0e0e0;
1871
  font-weight: 600;
1872
  }
1873
 
1874
+ .waiting-text {
1875
+ color: #9ca3af;
1876
+ font-style: italic;
1877
+ }
1878
+
1879
  .vs-divider {
1880
  color: #606060;
1881
  font-weight: 500;
 
1962
  }
1963
  }
1964
 
1965
+ .header-controls {
1966
+ display: flex;
1967
+ align-items: center;
1968
+ gap: 0.75rem;
1969
+ margin-left: auto;
1970
+
1971
+ .board-shape-select,
1972
+ .color-preference-select {
1973
+ background: rgba(0, 0, 0, 0.3);
1974
+ border: 1px solid rgba(255, 255, 255, 0.3);
1975
+ border-radius: 4px;
1976
+ color: #fff;
1977
+ padding: 0.4rem 0.6rem;
1978
+ font-size: 0.9rem;
1979
+ cursor: pointer;
1980
+ transition: background 0.2s;
1981
+
1982
+ &:hover {
1983
+ background: rgba(0, 0, 0, 0.4);
1984
+ }
1985
+
1986
+ &:disabled {
1987
+ opacity: 0.5;
1988
+ cursor: not-allowed;
1989
+ }
1990
+
1991
+ // Ensure dropdown options are readable
1992
+ option {
1993
+ background: #3a3a3a;
1994
+ color: #e0e0e0;
1995
+ }
1996
+ }
1997
+
1998
+ .color-preference-select {
1999
+ min-width: 130px;
2000
+ }
2001
+
2002
+ .board-shape-select {
2003
+ min-width: 90px;
2004
+ }
2005
+
2006
+ .btn-reset {
2007
+ background: #e94560;
2008
+ color: white;
2009
+ border: none;
2010
+ border-radius: 4px;
2011
+ padding: 0.4rem 1rem;
2012
+ font-size: 0.9rem;
2013
+ font-weight: 600;
2014
+ cursor: pointer;
2015
+ transition: background 0.2s;
2016
+
2017
+ &:hover {
2018
+ background: #d63850;
2019
+ }
2020
+
2021
+ &:active {
2022
+ background: #c02040;
2023
+ }
2024
+ }
2025
+ }
2026
+
2027
  .view-body {
2028
  display: flex;
2029
  flex: 1;
 
2389
  }
2390
  }
2391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2392
  }
2393
  }
2394
  }
trigo-web/app/vite.config.ts CHANGED
@@ -4,10 +4,12 @@ import { fileURLToPath, URL } from "node:url";
4
 
5
  // https://vitejs.dev/config/
6
  export default defineConfig(({ mode }) => {
7
- // Load env file based on `mode` in the current working directory.
8
- const env = loadEnv(mode, process.cwd(), "");
9
 
10
  return {
 
 
11
  plugins: [
12
  vue(),
13
  // Plugin to set correct MIME types
 
4
 
5
  // https://vitejs.dev/config/
6
  export default defineConfig(({ mode }) => {
7
+ // Load env file from repository root (parent directory)
8
+ const env = loadEnv(mode, fileURLToPath(new URL("..", import.meta.url)), "");
9
 
10
  return {
11
+ // Load .env files from repository root
12
+ envDir: "..",
13
  plugins: [
14
  vue(),
15
  // Plugin to set correct MIME types
trigo-web/backend/.env CHANGED
@@ -1,3 +1,9 @@
1
- PORT=3000
2
- CLIENT_URL=http://localhost:5173
3
- NODE_ENV=development
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # Backend Server Configuration
3
+ # ============================================================================
4
+ # This file is DEPRECATED - all configuration is now in the root .env file
5
+ # Backend should load environment variables from ../. env (repository root)
6
+ #
7
+ # To configure the backend, edit: ../. env
8
+ # ============================================================================
9
+
trigo-web/backend/.env.local ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+
2
+ PORT=8157
trigo-web/backend/package-lock.json CHANGED
@@ -22,6 +22,7 @@
22
  "@types/node": "^20.5.0",
23
  "nodemon": "^3.0.1",
24
  "ts-node": "^10.9.1",
 
25
  "typescript": "^5.2.2"
26
  }
27
  },
@@ -37,6 +38,422 @@
37
  "node": ">=12"
38
  }
39
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  "node_modules/@jridgewell/resolve-uri": {
41
  "version": "3.1.2",
42
  "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -613,6 +1030,47 @@
613
  "node": ">= 0.4"
614
  }
615
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
  "node_modules/escape-html": {
617
  "version": "1.0.3",
618
  "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -773,6 +1231,18 @@
773
  "node": ">= 0.4"
774
  }
775
  },
 
 
 
 
 
 
 
 
 
 
 
 
776
  "node_modules/glob-parent": {
777
  "version": "5.1.2",
778
  "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -1188,6 +1658,15 @@
1188
  "node": ">=8.10.0"
1189
  }
1190
  },
 
 
 
 
 
 
 
 
 
1191
  "node_modules/safe-buffer": {
1192
  "version": "5.2.1",
1193
  "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1552,6 +2031,25 @@
1552
  }
1553
  }
1554
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1555
  "node_modules/type-is": {
1556
  "version": "1.6.18",
1557
  "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
 
22
  "@types/node": "^20.5.0",
23
  "nodemon": "^3.0.1",
24
  "ts-node": "^10.9.1",
25
+ "tsx": "^4.20.6",
26
  "typescript": "^5.2.2"
27
  }
28
  },
 
38
  "node": ">=12"
39
  }
40
  },
41
+ "node_modules/@esbuild/aix-ppc64": {
42
+ "version": "0.25.12",
43
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
44
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
45
+ "cpu": [
46
+ "ppc64"
47
+ ],
48
+ "dev": true,
49
+ "optional": true,
50
+ "os": [
51
+ "aix"
52
+ ],
53
+ "engines": {
54
+ "node": ">=18"
55
+ }
56
+ },
57
+ "node_modules/@esbuild/android-arm": {
58
+ "version": "0.25.12",
59
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
60
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
61
+ "cpu": [
62
+ "arm"
63
+ ],
64
+ "dev": true,
65
+ "optional": true,
66
+ "os": [
67
+ "android"
68
+ ],
69
+ "engines": {
70
+ "node": ">=18"
71
+ }
72
+ },
73
+ "node_modules/@esbuild/android-arm64": {
74
+ "version": "0.25.12",
75
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
76
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
77
+ "cpu": [
78
+ "arm64"
79
+ ],
80
+ "dev": true,
81
+ "optional": true,
82
+ "os": [
83
+ "android"
84
+ ],
85
+ "engines": {
86
+ "node": ">=18"
87
+ }
88
+ },
89
+ "node_modules/@esbuild/android-x64": {
90
+ "version": "0.25.12",
91
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
92
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
93
+ "cpu": [
94
+ "x64"
95
+ ],
96
+ "dev": true,
97
+ "optional": true,
98
+ "os": [
99
+ "android"
100
+ ],
101
+ "engines": {
102
+ "node": ">=18"
103
+ }
104
+ },
105
+ "node_modules/@esbuild/darwin-arm64": {
106
+ "version": "0.25.12",
107
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
108
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
109
+ "cpu": [
110
+ "arm64"
111
+ ],
112
+ "dev": true,
113
+ "optional": true,
114
+ "os": [
115
+ "darwin"
116
+ ],
117
+ "engines": {
118
+ "node": ">=18"
119
+ }
120
+ },
121
+ "node_modules/@esbuild/darwin-x64": {
122
+ "version": "0.25.12",
123
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
124
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
125
+ "cpu": [
126
+ "x64"
127
+ ],
128
+ "dev": true,
129
+ "optional": true,
130
+ "os": [
131
+ "darwin"
132
+ ],
133
+ "engines": {
134
+ "node": ">=18"
135
+ }
136
+ },
137
+ "node_modules/@esbuild/freebsd-arm64": {
138
+ "version": "0.25.12",
139
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
140
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
141
+ "cpu": [
142
+ "arm64"
143
+ ],
144
+ "dev": true,
145
+ "optional": true,
146
+ "os": [
147
+ "freebsd"
148
+ ],
149
+ "engines": {
150
+ "node": ">=18"
151
+ }
152
+ },
153
+ "node_modules/@esbuild/freebsd-x64": {
154
+ "version": "0.25.12",
155
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
156
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
157
+ "cpu": [
158
+ "x64"
159
+ ],
160
+ "dev": true,
161
+ "optional": true,
162
+ "os": [
163
+ "freebsd"
164
+ ],
165
+ "engines": {
166
+ "node": ">=18"
167
+ }
168
+ },
169
+ "node_modules/@esbuild/linux-arm": {
170
+ "version": "0.25.12",
171
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
172
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
173
+ "cpu": [
174
+ "arm"
175
+ ],
176
+ "dev": true,
177
+ "optional": true,
178
+ "os": [
179
+ "linux"
180
+ ],
181
+ "engines": {
182
+ "node": ">=18"
183
+ }
184
+ },
185
+ "node_modules/@esbuild/linux-arm64": {
186
+ "version": "0.25.12",
187
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
188
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
189
+ "cpu": [
190
+ "arm64"
191
+ ],
192
+ "dev": true,
193
+ "optional": true,
194
+ "os": [
195
+ "linux"
196
+ ],
197
+ "engines": {
198
+ "node": ">=18"
199
+ }
200
+ },
201
+ "node_modules/@esbuild/linux-ia32": {
202
+ "version": "0.25.12",
203
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
204
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
205
+ "cpu": [
206
+ "ia32"
207
+ ],
208
+ "dev": true,
209
+ "optional": true,
210
+ "os": [
211
+ "linux"
212
+ ],
213
+ "engines": {
214
+ "node": ">=18"
215
+ }
216
+ },
217
+ "node_modules/@esbuild/linux-loong64": {
218
+ "version": "0.25.12",
219
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
220
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
221
+ "cpu": [
222
+ "loong64"
223
+ ],
224
+ "dev": true,
225
+ "optional": true,
226
+ "os": [
227
+ "linux"
228
+ ],
229
+ "engines": {
230
+ "node": ">=18"
231
+ }
232
+ },
233
+ "node_modules/@esbuild/linux-mips64el": {
234
+ "version": "0.25.12",
235
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
236
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
237
+ "cpu": [
238
+ "mips64el"
239
+ ],
240
+ "dev": true,
241
+ "optional": true,
242
+ "os": [
243
+ "linux"
244
+ ],
245
+ "engines": {
246
+ "node": ">=18"
247
+ }
248
+ },
249
+ "node_modules/@esbuild/linux-ppc64": {
250
+ "version": "0.25.12",
251
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
252
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
253
+ "cpu": [
254
+ "ppc64"
255
+ ],
256
+ "dev": true,
257
+ "optional": true,
258
+ "os": [
259
+ "linux"
260
+ ],
261
+ "engines": {
262
+ "node": ">=18"
263
+ }
264
+ },
265
+ "node_modules/@esbuild/linux-riscv64": {
266
+ "version": "0.25.12",
267
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
268
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
269
+ "cpu": [
270
+ "riscv64"
271
+ ],
272
+ "dev": true,
273
+ "optional": true,
274
+ "os": [
275
+ "linux"
276
+ ],
277
+ "engines": {
278
+ "node": ">=18"
279
+ }
280
+ },
281
+ "node_modules/@esbuild/linux-s390x": {
282
+ "version": "0.25.12",
283
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
284
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
285
+ "cpu": [
286
+ "s390x"
287
+ ],
288
+ "dev": true,
289
+ "optional": true,
290
+ "os": [
291
+ "linux"
292
+ ],
293
+ "engines": {
294
+ "node": ">=18"
295
+ }
296
+ },
297
+ "node_modules/@esbuild/linux-x64": {
298
+ "version": "0.25.12",
299
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
300
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
301
+ "cpu": [
302
+ "x64"
303
+ ],
304
+ "dev": true,
305
+ "optional": true,
306
+ "os": [
307
+ "linux"
308
+ ],
309
+ "engines": {
310
+ "node": ">=18"
311
+ }
312
+ },
313
+ "node_modules/@esbuild/netbsd-arm64": {
314
+ "version": "0.25.12",
315
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
316
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
317
+ "cpu": [
318
+ "arm64"
319
+ ],
320
+ "dev": true,
321
+ "optional": true,
322
+ "os": [
323
+ "netbsd"
324
+ ],
325
+ "engines": {
326
+ "node": ">=18"
327
+ }
328
+ },
329
+ "node_modules/@esbuild/netbsd-x64": {
330
+ "version": "0.25.12",
331
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
332
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
333
+ "cpu": [
334
+ "x64"
335
+ ],
336
+ "dev": true,
337
+ "optional": true,
338
+ "os": [
339
+ "netbsd"
340
+ ],
341
+ "engines": {
342
+ "node": ">=18"
343
+ }
344
+ },
345
+ "node_modules/@esbuild/openbsd-arm64": {
346
+ "version": "0.25.12",
347
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
348
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
349
+ "cpu": [
350
+ "arm64"
351
+ ],
352
+ "dev": true,
353
+ "optional": true,
354
+ "os": [
355
+ "openbsd"
356
+ ],
357
+ "engines": {
358
+ "node": ">=18"
359
+ }
360
+ },
361
+ "node_modules/@esbuild/openbsd-x64": {
362
+ "version": "0.25.12",
363
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
364
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
365
+ "cpu": [
366
+ "x64"
367
+ ],
368
+ "dev": true,
369
+ "optional": true,
370
+ "os": [
371
+ "openbsd"
372
+ ],
373
+ "engines": {
374
+ "node": ">=18"
375
+ }
376
+ },
377
+ "node_modules/@esbuild/openharmony-arm64": {
378
+ "version": "0.25.12",
379
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
380
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
381
+ "cpu": [
382
+ "arm64"
383
+ ],
384
+ "dev": true,
385
+ "optional": true,
386
+ "os": [
387
+ "openharmony"
388
+ ],
389
+ "engines": {
390
+ "node": ">=18"
391
+ }
392
+ },
393
+ "node_modules/@esbuild/sunos-x64": {
394
+ "version": "0.25.12",
395
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
396
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
397
+ "cpu": [
398
+ "x64"
399
+ ],
400
+ "dev": true,
401
+ "optional": true,
402
+ "os": [
403
+ "sunos"
404
+ ],
405
+ "engines": {
406
+ "node": ">=18"
407
+ }
408
+ },
409
+ "node_modules/@esbuild/win32-arm64": {
410
+ "version": "0.25.12",
411
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
412
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
413
+ "cpu": [
414
+ "arm64"
415
+ ],
416
+ "dev": true,
417
+ "optional": true,
418
+ "os": [
419
+ "win32"
420
+ ],
421
+ "engines": {
422
+ "node": ">=18"
423
+ }
424
+ },
425
+ "node_modules/@esbuild/win32-ia32": {
426
+ "version": "0.25.12",
427
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
428
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
429
+ "cpu": [
430
+ "ia32"
431
+ ],
432
+ "dev": true,
433
+ "optional": true,
434
+ "os": [
435
+ "win32"
436
+ ],
437
+ "engines": {
438
+ "node": ">=18"
439
+ }
440
+ },
441
+ "node_modules/@esbuild/win32-x64": {
442
+ "version": "0.25.12",
443
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
444
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
445
+ "cpu": [
446
+ "x64"
447
+ ],
448
+ "dev": true,
449
+ "optional": true,
450
+ "os": [
451
+ "win32"
452
+ ],
453
+ "engines": {
454
+ "node": ">=18"
455
+ }
456
+ },
457
  "node_modules/@jridgewell/resolve-uri": {
458
  "version": "3.1.2",
459
  "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
 
1030
  "node": ">= 0.4"
1031
  }
1032
  },
1033
+ "node_modules/esbuild": {
1034
+ "version": "0.25.12",
1035
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
1036
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
1037
+ "dev": true,
1038
+ "hasInstallScript": true,
1039
+ "bin": {
1040
+ "esbuild": "bin/esbuild"
1041
+ },
1042
+ "engines": {
1043
+ "node": ">=18"
1044
+ },
1045
+ "optionalDependencies": {
1046
+ "@esbuild/aix-ppc64": "0.25.12",
1047
+ "@esbuild/android-arm": "0.25.12",
1048
+ "@esbuild/android-arm64": "0.25.12",
1049
+ "@esbuild/android-x64": "0.25.12",
1050
+ "@esbuild/darwin-arm64": "0.25.12",
1051
+ "@esbuild/darwin-x64": "0.25.12",
1052
+ "@esbuild/freebsd-arm64": "0.25.12",
1053
+ "@esbuild/freebsd-x64": "0.25.12",
1054
+ "@esbuild/linux-arm": "0.25.12",
1055
+ "@esbuild/linux-arm64": "0.25.12",
1056
+ "@esbuild/linux-ia32": "0.25.12",
1057
+ "@esbuild/linux-loong64": "0.25.12",
1058
+ "@esbuild/linux-mips64el": "0.25.12",
1059
+ "@esbuild/linux-ppc64": "0.25.12",
1060
+ "@esbuild/linux-riscv64": "0.25.12",
1061
+ "@esbuild/linux-s390x": "0.25.12",
1062
+ "@esbuild/linux-x64": "0.25.12",
1063
+ "@esbuild/netbsd-arm64": "0.25.12",
1064
+ "@esbuild/netbsd-x64": "0.25.12",
1065
+ "@esbuild/openbsd-arm64": "0.25.12",
1066
+ "@esbuild/openbsd-x64": "0.25.12",
1067
+ "@esbuild/openharmony-arm64": "0.25.12",
1068
+ "@esbuild/sunos-x64": "0.25.12",
1069
+ "@esbuild/win32-arm64": "0.25.12",
1070
+ "@esbuild/win32-ia32": "0.25.12",
1071
+ "@esbuild/win32-x64": "0.25.12"
1072
+ }
1073
+ },
1074
  "node_modules/escape-html": {
1075
  "version": "1.0.3",
1076
  "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
 
1231
  "node": ">= 0.4"
1232
  }
1233
  },
1234
+ "node_modules/get-tsconfig": {
1235
+ "version": "4.13.0",
1236
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
1237
+ "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
1238
+ "dev": true,
1239
+ "dependencies": {
1240
+ "resolve-pkg-maps": "^1.0.0"
1241
+ },
1242
+ "funding": {
1243
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
1244
+ }
1245
+ },
1246
  "node_modules/glob-parent": {
1247
  "version": "5.1.2",
1248
  "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
 
1658
  "node": ">=8.10.0"
1659
  }
1660
  },
1661
+ "node_modules/resolve-pkg-maps": {
1662
+ "version": "1.0.0",
1663
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
1664
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
1665
+ "dev": true,
1666
+ "funding": {
1667
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
1668
+ }
1669
+ },
1670
  "node_modules/safe-buffer": {
1671
  "version": "5.2.1",
1672
  "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
 
2031
  }
2032
  }
2033
  },
2034
+ "node_modules/tsx": {
2035
+ "version": "4.20.6",
2036
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz",
2037
+ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
2038
+ "dev": true,
2039
+ "dependencies": {
2040
+ "esbuild": "~0.25.0",
2041
+ "get-tsconfig": "^4.7.5"
2042
+ },
2043
+ "bin": {
2044
+ "tsx": "dist/cli.mjs"
2045
+ },
2046
+ "engines": {
2047
+ "node": ">=18.0.0"
2048
+ },
2049
+ "optionalDependencies": {
2050
+ "fsevents": "~2.3.3"
2051
+ }
2052
+ },
2053
  "node_modules/type-is": {
2054
  "version": "1.6.18",
2055
  "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
trigo-web/backend/package.json CHANGED
@@ -1,10 +1,11 @@
1
  {
2
  "name": "trigo-backend",
3
  "version": "1.0.0",
 
4
  "description": "Backend server for Trigo game",
5
  "main": "dist/backend/src/server.js",
6
  "scripts": {
7
- "dev": "nodemon --watch src --exec ts-node src/server.ts",
8
  "build": "tsc",
9
  "start": "node dist/backend/src/server.js",
10
  "test": "echo \"Error: no test specified\" && exit 1"
@@ -31,6 +32,7 @@
31
  "@types/node": "^20.5.0",
32
  "nodemon": "^3.0.1",
33
  "ts-node": "^10.9.1",
 
34
  "typescript": "^5.2.2"
35
  }
36
  }
 
1
  {
2
  "name": "trigo-backend",
3
  "version": "1.0.0",
4
+ "type": "module",
5
  "description": "Backend server for Trigo game",
6
  "main": "dist/backend/src/server.js",
7
  "scripts": {
8
+ "dev": "nodemon --watch src --exec tsx src/server.ts",
9
  "build": "tsc",
10
  "start": "node dist/backend/src/server.js",
11
  "test": "echo \"Error: no test specified\" && exit 1"
 
32
  "@types/node": "^20.5.0",
33
  "nodemon": "^3.0.1",
34
  "ts-node": "^10.9.1",
35
+ "tsx": "^4.20.6",
36
  "typescript": "^5.2.2"
37
  }
38
  }
trigo-web/backend/src/server.ts CHANGED
@@ -1,11 +1,42 @@
1
- const express = require("express");
2
- const { createServer } = require("http");
3
- const { Server } = require("socket.io");
4
- const cors = require("cors");
5
- const dotenv = require("dotenv");
6
- const path = require("path");
 
 
 
 
7
 
8
- dotenv.config();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  const app = express();
11
  const httpServer = createServer(app);
@@ -20,8 +51,17 @@ const io = new Server(httpServer, {
20
  }
21
  });
22
 
23
- const PORT = process.env.PORT || 3000;
24
- const HOST = process.env.HOST || "localhost";
 
 
 
 
 
 
 
 
 
25
 
26
  // Middleware
27
  app.use(cors());
@@ -50,7 +90,10 @@ app.get("/health", (_req: any, res: any) => {
50
  io.on("connection", (socket: any) => {
51
  console.log(`New client connected: ${socket.id}`);
52
 
53
- // Echo test handler
 
 
 
54
  socket.on("echo", (data: any, callback: any) => {
55
  const timestamp = new Date().toISOString();
56
  const responseMessage = `Hello from server! Received: "${data.message}" at ${timestamp}`;
 
1
+ import express from "express";
2
+ import { createServer } from "http";
3
+ import { Server } from "socket.io";
4
+ import cors from "cors";
5
+ import dotenv from "dotenv";
6
+ import path from "path";
7
+ import fs from "fs";
8
+ import { fileURLToPath } from "url";
9
+ import { GameManager } from "./services/gameManager";
10
+ import { setupSocketHandlers } from "./sockets/gameSocket";
11
 
12
+ // Get __dirname equivalent in ES modules
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+
16
+ // Load environment variables
17
+ // Priority: .env.local > .env (load .env first, then .env.local overwrites)
18
+ // When running via ts-node: __dirname = backend/src/, so go up 1 level
19
+ // When running compiled: __dirname = dist/backend/src/, so go up 3 levels
20
+ const isDev = __dirname.includes("/src") && !__dirname.includes("/dist");
21
+ const levelsUp = isDev ? "../" : "../../../";
22
+ const envPath = path.join(__dirname, levelsUp, ".env");
23
+ const envLocalPath = path.join(__dirname, levelsUp, ".env.local");
24
+
25
+ // Load .env first (base configuration)
26
+ if (fs.existsSync(envPath)) {
27
+ dotenv.config({ path: envPath });
28
+ console.log("[Config] Loaded .env");
29
+ } else {
30
+ console.log(`[Config] .env not found at: ${envPath}`);
31
+ }
32
+
33
+ // Load .env.local second (overrides .env for local development)
34
+ if (fs.existsSync(envLocalPath)) {
35
+ dotenv.config({ path: envLocalPath, override: true });
36
+ console.log("[Config] Loaded .env.local (overriding .env)");
37
+ } else {
38
+ console.log(`[Config] .env.local not found at: ${envLocalPath}`);
39
+ }
40
 
41
  const app = express();
42
  const httpServer = createServer(app);
 
51
  }
52
  });
53
 
54
+ // Create GameManager instance
55
+ const gameManager = new GameManager();
56
+
57
+ const PORT = parseInt(process.env.PORT || "3000", 10);
58
+ const HOST = process.env.HOST || "0.0.0.0";
59
+
60
+ console.log(`[Config] Server Configuration:`);
61
+ console.log(`[Config] PORT: ${PORT}`);
62
+ console.log(`[Config] HOST: ${HOST}`);
63
+ console.log(`[Config] NODE_ENV: ${process.env.NODE_ENV || "development"}`);
64
+ console.log(`[Config] CLIENT_URL: ${process.env.CLIENT_URL || "not set"}`);
65
 
66
  // Middleware
67
  app.use(cors());
 
90
  io.on("connection", (socket: any) => {
91
  console.log(`New client connected: ${socket.id}`);
92
 
93
+ // Setup game-related socket handlers
94
+ setupSocketHandlers(io, socket, gameManager);
95
+
96
+ // Echo test handler (for testing)
97
  socket.on("echo", (data: any, callback: any) => {
98
  const timestamp = new Date().toISOString();
99
  const responseMessage = `Hello from server! Received: "${data.message}" at ${timestamp}`;
trigo-web/backend/src/services/gameManager.ts CHANGED
@@ -16,6 +16,7 @@ export interface GameState {
16
 
17
  export interface GameRoom {
18
  id: string;
 
19
  players: { [playerId: string]: Player };
20
  game: TrigoGame; // The actual game instance
21
  gameState: GameState; // Game status metadata
@@ -32,17 +33,21 @@ export class GameManager {
32
  console.log("GameManager initialized");
33
  }
34
 
35
- createRoom(playerId: string, nickname: string, boardShape?: BoardShape): GameRoom | null {
36
  const roomId = this.generateRoomId();
37
  const shape = boardShape || this.defaultBoardShape;
38
 
 
 
 
39
  const room: GameRoom = {
40
  id: roomId,
 
41
  players: {
42
  [playerId]: {
43
  id: playerId,
44
  nickname,
45
- color: "black",
46
  connected: true
47
  }
48
  },
@@ -72,7 +77,7 @@ export class GameManager {
72
  return room;
73
  }
74
 
75
- joinRoom(roomId: string, playerId: string, nickname: string): GameRoom | null {
76
  const room = this.rooms.get(roomId);
77
  if (!room) {
78
  return null;
@@ -83,11 +88,22 @@ export class GameManager {
83
  return null; // Room is full
84
  }
85
 
86
- // Assign white color to the second player
 
 
 
 
 
 
 
 
 
 
 
87
  room.players[playerId] = {
88
  id: playerId,
89
  nickname,
90
- color: "white",
91
  connected: true
92
  };
93
 
@@ -207,6 +223,98 @@ export class GameManager {
207
  return room.game.undo();
208
  }
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  /**
211
  * Get game board state for a room
212
  */
 
16
 
17
  export interface GameRoom {
18
  id: string;
19
+ adminId: string; // Room creator who has admin permissions
20
  players: { [playerId: string]: Player };
21
  game: TrigoGame; // The actual game instance
22
  gameState: GameState; // Game status metadata
 
33
  console.log("GameManager initialized");
34
  }
35
 
36
+ createRoom(playerId: string, nickname: string, boardShape?: BoardShape, preferredColor?: "black" | "white"): GameRoom | null {
37
  const roomId = this.generateRoomId();
38
  const shape = boardShape || this.defaultBoardShape;
39
 
40
+ // Use preferred color if specified, default to black
41
+ const playerColor = preferredColor || "black";
42
+
43
  const room: GameRoom = {
44
  id: roomId,
45
+ adminId: playerId, // Room creator is admin
46
  players: {
47
  [playerId]: {
48
  id: playerId,
49
  nickname,
50
+ color: playerColor,
51
  connected: true
52
  }
53
  },
 
77
  return room;
78
  }
79
 
80
+ joinRoom(roomId: string, playerId: string, nickname: string, preferredColor?: "black" | "white"): GameRoom | null {
81
  const room = this.rooms.get(roomId);
82
  if (!room) {
83
  return null;
 
88
  return null; // Room is full
89
  }
90
 
91
+ // Try to assign preferred color if specified
92
+ const firstPlayer = Object.values(room.players)[0];
93
+ let assignedColor: "black" | "white";
94
+
95
+ if (preferredColor && preferredColor !== firstPlayer.color) {
96
+ // Preferred color is available
97
+ assignedColor = preferredColor;
98
+ } else {
99
+ // Assign opposite of first player's color
100
+ assignedColor = firstPlayer.color === "black" ? "white" : "black";
101
+ }
102
+
103
  room.players[playerId] = {
104
  id: playerId,
105
  nickname,
106
+ color: assignedColor,
107
  connected: true
108
  };
109
 
 
223
  return room.game.undo();
224
  }
225
 
226
+
227
+ /**
228
+ * Redo the last undone move (forward in history)
229
+ */
230
+ redoMove(roomId: string, playerId: string): boolean {
231
+ const room = this.rooms.get(roomId);
232
+ if (!room) return false;
233
+
234
+ const player = room.players[playerId];
235
+ if (!player) return false;
236
+
237
+ if (room.gameState.gameStatus !== "playing") {
238
+ return false;
239
+ }
240
+
241
+ return room.game.redo();
242
+ }
243
+
244
+
245
+ /**
246
+ * Reset the game to initial state (new game in same room)
247
+ * Only admin can reset the game
248
+ */
249
+ resetGame(
250
+ roomId: string,
251
+ adminId: string,
252
+ options?: {
253
+ boardShape?: BoardShape;
254
+ playerColors?: { [playerId: string]: "black" | "white" };
255
+ }
256
+ ): { success: boolean; error?: string } {
257
+ const room = this.rooms.get(roomId);
258
+ if (!room) return { success: false, error: "Room not found" };
259
+
260
+ // Check admin permission
261
+ if (room.adminId !== adminId) {
262
+ return { success: false, error: "Only room admin can reset the game" };
263
+ }
264
+
265
+ const boardShape = options?.boardShape;
266
+ const playerColors = options?.playerColors;
267
+
268
+ // Apply player color assignments if provided
269
+ if (playerColors) {
270
+ const playerIds = Object.keys(room.players);
271
+ for (const playerId of playerIds) {
272
+ if (playerColors[playerId]) {
273
+ room.players[playerId].color = playerColors[playerId];
274
+ }
275
+ }
276
+ console.log(`Player colors assigned:`, playerColors);
277
+ }
278
+
279
+ // If board shape is provided and different from current, create a new game
280
+ if (boardShape) {
281
+ const currentShape = room.game.getShape();
282
+ if (
283
+ boardShape.x !== currentShape.x ||
284
+ boardShape.y !== currentShape.y ||
285
+ boardShape.z !== currentShape.z
286
+ ) {
287
+ // Create new game with new board shape
288
+ room.game = new TrigoGame(boardShape, {
289
+ onStepAdvance: (_step, history) => {
290
+ console.log(`Step ${history.length}: Player made move`);
291
+ },
292
+ onCapture: (captured) => {
293
+ console.log(`Captured ${captured.length} stones`);
294
+ },
295
+ onWin: (winner) => {
296
+ console.log(`Game won by ${winner}`);
297
+ }
298
+ });
299
+ console.log(`Game ${roomId} reset with new board shape: ${boardShape.x}x${boardShape.y}x${boardShape.z}`);
300
+ } else {
301
+ // Same shape, just reset
302
+ room.game.reset();
303
+ console.log(`Game ${roomId} reset to initial state`);
304
+ }
305
+ } else {
306
+ // No shape provided, just reset
307
+ room.game.reset();
308
+ console.log(`Game ${roomId} reset to initial state`);
309
+ }
310
+
311
+ room.gameState.gameStatus = "playing";
312
+ room.gameState.winner = null;
313
+ room.startedAt = new Date();
314
+
315
+ return { success: true };
316
+ }
317
+
318
  /**
319
  * Get game board state for a room
320
  */
trigo-web/backend/src/sockets/gameSocket.ts CHANGED
@@ -5,52 +5,141 @@ export function setupSocketHandlers(io: Server, socket: Socket, gameManager: Gam
5
  console.log(`Setting up socket handlers for ${socket.id}`);
6
 
7
  // Join room
8
- socket.on("joinRoom", (data: { roomId?: string; nickname: string }) => {
9
- const { roomId, nickname } = data;
 
 
 
 
 
 
 
 
10
 
11
- // Create or join room
12
- const room = roomId
13
- ? gameManager.joinRoom(roomId, socket.id, nickname)
14
- : gameManager.createRoom(socket.id, nickname);
15
 
16
- if (room) {
17
- socket.join(room.id);
 
18
 
19
- // Get complete game data for frontend
20
- const board = gameManager.getGameBoard(room.id);
21
- const currentPlayer = gameManager.getCurrentPlayer(room.id);
22
- const stats = gameManager.getGameStats(room.id);
23
 
24
- socket.emit("roomJoined", {
25
- roomId: room.id,
26
- playerId: socket.id,
27
- playerColor: room.players[socket.id]?.color,
28
- gameState: {
29
- board,
30
- boardShape: room.game.getShape(),
31
- currentPlayer,
32
- moveHistory: room.game.getHistory(),
33
- currentMoveIndex: room.game.getCurrentStep(),
34
- capturedStones: {
35
- black: stats?.capturedByBlack || 0,
36
- white: stats?.capturedByWhite || 0
37
- },
38
- gameStatus: room.gameState.gameStatus,
39
- winner: room.gameState.winner
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  }
41
- });
42
 
43
- // Notify other players
44
- socket.to(room.id).emit("playerJoined", {
45
- playerId: socket.id,
46
- nickname: nickname
47
- });
48
 
49
- console.log(`Player ${socket.id} joined room ${room.id}`);
50
- } else {
51
- socket.emit("error", { message: "Failed to join room" });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  }
53
- });
54
 
55
  // Leave room
56
  socket.on("leaveRoom", () => {
@@ -71,23 +160,23 @@ export function setupSocketHandlers(io: Server, socket: Socket, gameManager: Gam
71
  const room = gameManager.getPlayerRoom(socket.id);
72
  if (room && gameManager.makeMove(room.id, socket.id, data)) {
73
  // Get updated game data
74
- const board = gameManager.getGameBoard(room.id);
75
  const currentPlayer = gameManager.getCurrentPlayer(room.id);
76
  const stats = gameManager.getGameStats(room.id);
77
  const lastStep = room.game.getLastStep();
 
78
 
79
  // Broadcast game update to all players in the room
80
  io.to(room.id).emit("gameUpdate", {
81
- board,
82
  currentPlayer,
 
83
  lastMove: data,
84
  capturedStones: {
85
  black: stats?.capturedByBlack || 0,
86
  white: stats?.capturedByWhite || 0
87
  },
88
  capturedPositions: lastStep?.capturedPositions,
89
- moveHistory: room.game.getHistory(),
90
- currentMoveIndex: room.game.getCurrentStep()
91
  });
92
  } else {
93
  socket.emit("error", { message: "Invalid move" });
@@ -99,12 +188,13 @@ export function setupSocketHandlers(io: Server, socket: Socket, gameManager: Gam
99
  const room = gameManager.getPlayerRoom(socket.id);
100
  if (room && gameManager.passTurn(room.id, socket.id)) {
101
  const currentPlayer = gameManager.getCurrentPlayer(room.id);
 
102
 
103
  io.to(room.id).emit("gameUpdate", {
104
  currentPlayer,
105
  action: "pass",
106
- moveHistory: room.game.getHistory(),
107
- currentMoveIndex: room.game.getCurrentStep()
108
  });
109
 
110
  // Check for consecutive passes (game end)
@@ -130,6 +220,150 @@ export function setupSocketHandlers(io: Server, socket: Socket, gameManager: Gam
130
  }
131
  });
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  // Chat messages
134
  socket.on("chatMessage", (data: { content: string }) => {
135
  const room = gameManager.getPlayerRoom(socket.id);
@@ -143,6 +377,48 @@ export function setupSocketHandlers(io: Server, socket: Socket, gameManager: Gam
143
  }
144
  });
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  // Handle disconnection
147
  socket.on("disconnect", () => {
148
  console.log(`Client disconnected: ${socket.id}`);
@@ -155,3 +431,33 @@ export function setupSocketHandlers(io: Server, socket: Socket, gameManager: Gam
155
  }
156
  });
157
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  console.log(`Setting up socket handlers for ${socket.id}`);
6
 
7
  // Join room
8
+ socket.on(
9
+ "joinRoom",
10
+ (data: { roomId?: string; nickname: string; preferredColor?: "black" | "white" }, callback?: (response: any) => void) => {
11
+ console.log("[gameSocket] joinRoom event received:", {
12
+ roomId: data.roomId,
13
+ nickname: data.nickname,
14
+ preferredColor: data.preferredColor,
15
+ hasCallback: !!callback,
16
+ socketId: socket.id
17
+ });
18
 
19
+ const { roomId, nickname, preferredColor } = data;
 
 
 
20
 
21
+ // Try to create or join room
22
+ try {
23
+ let room;
24
 
25
+ if (roomId) {
26
+ // Joining existing room
27
+ const existingRoom = gameManager.getRoom(roomId);
 
28
 
29
+ if (!existingRoom) {
30
+ // Room doesn't exist
31
+ if (callback) {
32
+ callback({
33
+ success: false,
34
+ error: "Room not found",
35
+ errorCode: "ROOM_NOT_FOUND"
36
+ });
37
+ }
38
+ return;
39
+ }
40
+
41
+ const playerCount = Object.keys(existingRoom.players).length;
42
+ if (playerCount >= 2) {
43
+ // Room is full
44
+ if (callback) {
45
+ callback({
46
+ success: false,
47
+ error: "Room is full",
48
+ errorCode: "ROOM_FULL"
49
+ });
50
+ }
51
+ return;
52
+ }
53
+
54
+ room = gameManager.joinRoom(roomId, socket.id, nickname, preferredColor);
55
+ } else {
56
+ // Creating new room
57
+ room = gameManager.createRoom(socket.id, nickname, undefined, preferredColor);
58
  }
 
59
 
60
+ if (room) {
61
+ socket.join(room.id);
 
 
 
62
 
63
+ // Get complete game data for frontend
64
+ const currentPlayer = gameManager.getCurrentPlayer(room.id);
65
+ const stats = gameManager.getGameStats(room.id);
66
+ const tgn = room.game.toTGN();
67
+
68
+ // Get player list with colors
69
+ const players: { [playerId: string]: { nickname: string; color: "black" | "white" } } = {};
70
+ for (const [pid, player] of Object.entries(room.players)) {
71
+ players[pid] = {
72
+ nickname: player.nickname,
73
+ color: player.color
74
+ };
75
+ }
76
+
77
+ const response = {
78
+ success: true,
79
+ roomId: room.id,
80
+ playerId: socket.id,
81
+ playerColor: room.players[socket.id]?.color,
82
+ isAdmin: room.adminId === socket.id,
83
+ adminId: room.adminId,
84
+ players, // Include all players in room
85
+ gameState: {
86
+ boardShape: room.game.getShape(),
87
+ currentPlayer,
88
+ currentMoveIndex: room.game.getCurrentStep(),
89
+ capturedStones: {
90
+ black: stats?.capturedByBlack || 0,
91
+ white: stats?.capturedByWhite || 0
92
+ },
93
+ gameStatus: room.gameState.gameStatus,
94
+ winner: room.gameState.winner,
95
+ tgn
96
+ }
97
+ };
98
+
99
+ // Send response via callback
100
+ if (callback) {
101
+ console.log("[gameSocket] Sending response via callback:", {
102
+ roomId: response.roomId,
103
+ playerColor: response.playerColor
104
+ });
105
+ callback(response);
106
+ } else {
107
+ // Fallback to event emit for backward compatibility
108
+ console.log("[gameSocket] No callback, using roomJoined emit");
109
+ socket.emit("roomJoined", response);
110
+ }
111
+
112
+ // Notify other players
113
+ socket.to(room.id).emit("playerJoined", {
114
+ playerId: socket.id,
115
+ nickname: nickname
116
+ });
117
+
118
+ console.log(
119
+ `Player ${socket.id} ${roomId ? "joined" : "created"} room ${room.id}`
120
+ );
121
+ } else {
122
+ // Generic failure
123
+ if (callback) {
124
+ callback({
125
+ success: false,
126
+ error: "Failed to join or create room",
127
+ errorCode: "UNKNOWN_ERROR"
128
+ });
129
+ }
130
+ }
131
+ } catch (error) {
132
+ console.error(`Error in joinRoom handler:`, error);
133
+ if (callback) {
134
+ callback({
135
+ success: false,
136
+ error: "Server error",
137
+ errorCode: "SERVER_ERROR"
138
+ });
139
+ }
140
+ }
141
  }
142
+ );
143
 
144
  // Leave room
145
  socket.on("leaveRoom", () => {
 
160
  const room = gameManager.getPlayerRoom(socket.id);
161
  if (room && gameManager.makeMove(room.id, socket.id, data)) {
162
  // Get updated game data
 
163
  const currentPlayer = gameManager.getCurrentPlayer(room.id);
164
  const stats = gameManager.getGameStats(room.id);
165
  const lastStep = room.game.getLastStep();
166
+ const tgn = room.game.toTGN();
167
 
168
  // Broadcast game update to all players in the room
169
  io.to(room.id).emit("gameUpdate", {
 
170
  currentPlayer,
171
+ action: "move",
172
  lastMove: data,
173
  capturedStones: {
174
  black: stats?.capturedByBlack || 0,
175
  white: stats?.capturedByWhite || 0
176
  },
177
  capturedPositions: lastStep?.capturedPositions,
178
+ currentMoveIndex: room.game.getCurrentStep(),
179
+ tgn
180
  });
181
  } else {
182
  socket.emit("error", { message: "Invalid move" });
 
188
  const room = gameManager.getPlayerRoom(socket.id);
189
  if (room && gameManager.passTurn(room.id, socket.id)) {
190
  const currentPlayer = gameManager.getCurrentPlayer(room.id);
191
+ const tgn = room.game.toTGN();
192
 
193
  io.to(room.id).emit("gameUpdate", {
194
  currentPlayer,
195
  action: "pass",
196
+ currentMoveIndex: room.game.getCurrentStep(),
197
+ tgn
198
  });
199
 
200
  // Check for consecutive passes (game end)
 
220
  }
221
  });
222
 
223
+
224
+ // Undo move
225
+ socket.on("undoMove", (callback?: (response: any) => void) => {
226
+ const room = gameManager.getPlayerRoom(socket.id);
227
+
228
+ if (!room) {
229
+ if (callback) callback({ success: false, error: "Not in a room", errorCode: "NOT_IN_ROOM" });
230
+ return;
231
+ }
232
+
233
+ if (room.gameState.gameStatus !== "playing") {
234
+ if (callback) callback({ success: false, error: "Game not active", errorCode: "GAME_NOT_ACTIVE" });
235
+ return;
236
+ }
237
+
238
+ const success = gameManager.undoMove(room.id, socket.id);
239
+
240
+ if (success) {
241
+ const currentPlayer = gameManager.getCurrentPlayer(room.id);
242
+ const stats = gameManager.getGameStats(room.id);
243
+ const tgn = room.game.toTGN();
244
+
245
+ io.to(room.id).emit("gameUpdate", {
246
+ currentPlayer,
247
+ action: "undo",
248
+ currentMoveIndex: room.game.getCurrentStep(),
249
+ capturedStones: {
250
+ black: stats?.capturedByBlack || 0,
251
+ white: stats?.capturedByWhite || 0
252
+ },
253
+ tgn
254
+ });
255
+
256
+ if (callback) callback({ success: true });
257
+ } else {
258
+ if (callback) callback({ success: false, error: "Cannot undo", errorCode: "UNDO_FAILED" });
259
+ }
260
+ });
261
+
262
+
263
+ // Redo move
264
+ socket.on("redoMove", (callback?: (response: any) => void) => {
265
+ const room = gameManager.getPlayerRoom(socket.id);
266
+
267
+ if (!room) {
268
+ if (callback) callback({ success: false, error: "Not in a room", errorCode: "NOT_IN_ROOM" });
269
+ return;
270
+ }
271
+
272
+ if (room.gameState.gameStatus !== "playing") {
273
+ if (callback) callback({ success: false, error: "Game not active", errorCode: "GAME_NOT_ACTIVE" });
274
+ return;
275
+ }
276
+
277
+ if (!room.game.canRedo()) {
278
+ if (callback) callback({ success: false, error: "Nothing to redo", errorCode: "NOTHING_TO_REDO" });
279
+ return;
280
+ }
281
+
282
+ const success = gameManager.redoMove(room.id, socket.id);
283
+
284
+ if (success) {
285
+ const currentPlayer = gameManager.getCurrentPlayer(room.id);
286
+ const stats = gameManager.getGameStats(room.id);
287
+ const lastStep = room.game.getLastStep();
288
+ const tgn = room.game.toTGN();
289
+
290
+ io.to(room.id).emit("gameUpdate", {
291
+ currentPlayer,
292
+ action: "redo",
293
+ lastMove: lastStep?.position,
294
+ capturedStones: {
295
+ black: stats?.capturedByBlack || 0,
296
+ white: stats?.capturedByWhite || 0
297
+ },
298
+ capturedPositions: lastStep?.capturedPositions,
299
+ currentMoveIndex: room.game.getCurrentStep(),
300
+ tgn
301
+ });
302
+
303
+ if (callback) callback({ success: true });
304
+ } else {
305
+ if (callback) callback({ success: false, error: "Redo failed", errorCode: "REDO_FAILED" });
306
+ }
307
+ });
308
+
309
+
310
+ // Reset game
311
+ socket.on("resetGame", (
312
+ data?: {
313
+ boardShape?: { x: number; y: number; z: number };
314
+ playerColors?: { [playerId: string]: "black" | "white" };
315
+ } | ((response: any) => void),
316
+ callback?: (response: any) => void
317
+ ) => {
318
+ const room = gameManager.getPlayerRoom(socket.id);
319
+
320
+ if (!room) {
321
+ const cb = typeof data === 'function' ? data : callback;
322
+ if (cb) cb({ success: false, error: "Not in a room", errorCode: "NOT_IN_ROOM" });
323
+ return;
324
+ }
325
+
326
+ // Handle both old signature (no data) and new signature (with options)
327
+ const options = typeof data === 'object' && data !== null ? {
328
+ boardShape: data.boardShape,
329
+ playerColors: data.playerColors
330
+ } : undefined;
331
+ const responseCb = typeof data === 'function' ? data : callback;
332
+
333
+ const result = gameManager.resetGame(room.id, socket.id, options);
334
+
335
+ if (result.success) {
336
+ const currentPlayer = gameManager.getCurrentPlayer(room.id);
337
+ const tgn = room.game.toTGN();
338
+
339
+ // Get updated player info with new colors
340
+ const players: { [playerId: string]: { nickname: string; color: "black" | "white" } } = {};
341
+ for (const [pid, player] of Object.entries(room.players)) {
342
+ players[pid] = {
343
+ nickname: player.nickname,
344
+ color: player.color
345
+ };
346
+ }
347
+
348
+ io.to(room.id).emit("gameReset", {
349
+ currentPlayer,
350
+ boardShape: room.game.getShape(),
351
+ currentMoveIndex: 0,
352
+ capturedStones: { black: 0, white: 0 },
353
+ players,
354
+ tgn
355
+ });
356
+
357
+ if (responseCb) responseCb({ success: true });
358
+ } else {
359
+ if (responseCb) responseCb({
360
+ success: false,
361
+ error: result.error || "Reset failed",
362
+ errorCode: result.error === "Only room admin can reset the game" ? "NOT_ADMIN" : "RESET_FAILED"
363
+ });
364
+ }
365
+ });
366
+
367
  // Chat messages
368
  socket.on("chatMessage", (data: { content: string }) => {
369
  const room = gameManager.getPlayerRoom(socket.id);
 
377
  }
378
  });
379
 
380
+ // Change nickname
381
+ socket.on(
382
+ "changeNickname",
383
+ (data: { nickname: string }, callback?: (response: any) => void) => {
384
+ const room = gameManager.getPlayerRoom(socket.id);
385
+
386
+ if (!room) {
387
+ const error = { success: false, error: "Not in a room" };
388
+ if (callback) callback(error);
389
+ return;
390
+ }
391
+
392
+ // Validate nickname
393
+ const validation = validateNickname(data.nickname);
394
+ if (!validation.valid) {
395
+ const error = { success: false, error: validation.error };
396
+ if (callback) callback(error);
397
+ return;
398
+ }
399
+
400
+ // Update player nickname
401
+ const player = room.players[socket.id];
402
+ if (player) {
403
+ const oldNickname = player.nickname;
404
+ player.nickname = data.nickname;
405
+
406
+ // Broadcast to all players in room
407
+ io.to(room.id).emit("nicknameChanged", {
408
+ playerId: socket.id,
409
+ nickname: data.nickname,
410
+ oldNickname: oldNickname
411
+ });
412
+
413
+ console.log(`Player ${socket.id} changed nickname: ${oldNickname} -> ${data.nickname}`);
414
+
415
+ if (callback) {
416
+ callback({ success: true, nickname: data.nickname });
417
+ }
418
+ }
419
+ }
420
+ );
421
+
422
  // Handle disconnection
423
  socket.on("disconnect", () => {
424
  console.log(`Client disconnected: ${socket.id}`);
 
431
  }
432
  });
433
  }
434
+
435
+
436
+ /**
437
+ * Validate nickname according to the rules:
438
+ * - Length: 3-20 characters
439
+ * - Characters: alphanumeric + spaces only
440
+ * - No leading/trailing whitespace
441
+ */
442
+ function validateNickname(nickname: string): { valid: boolean; error?: string } {
443
+ if (!nickname || typeof nickname !== "string") {
444
+ return { valid: false, error: "Invalid nickname" };
445
+ }
446
+
447
+ const trimmed = nickname.trim();
448
+
449
+ if (trimmed.length < 3) {
450
+ return { valid: false, error: "Nickname must be at least 3 characters" };
451
+ }
452
+ if (trimmed.length > 20) {
453
+ return { valid: false, error: "Nickname must be 20 characters or less" };
454
+ }
455
+ if (!/^[a-zA-Z0-9 ]+$/.test(trimmed)) {
456
+ return { valid: false, error: "Only letters, numbers, and spaces allowed" };
457
+ }
458
+ if (trimmed !== nickname) {
459
+ return { valid: false, error: "No leading or trailing spaces allowed" };
460
+ }
461
+
462
+ return { valid: true };
463
+ }
trigo-web/inc/config.ts ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Configuration Utilities
3
+ *
4
+ * Provides centralized access to environment variables for ONNX model paths
5
+ * and other configuration values.
6
+ *
7
+ * Works across different contexts:
8
+ * - Frontend (Vite): Uses import.meta.env.VITE_* variables
9
+ * - Backend/Tools (Node): Uses process.env.* variables with dotenv
10
+ */
11
+
12
+ import * as path from "path";
13
+ import { fileURLToPath } from "url";
14
+
15
+
16
+ // Environment detection
17
+ const isNode = typeof process !== "undefined" && process.versions?.node;
18
+ const isBrowser = typeof window !== "undefined";
19
+
20
+
21
+ /**
22
+ * ONNX Session Options
23
+ */
24
+ export interface OnnxSessionOptions {
25
+ executionProviders: string[];
26
+ intraOpNumThreads?: number;
27
+ interOpNumThreads?: number;
28
+ graphOptimizationLevel?: "disabled" | "basic" | "extended" | "all";
29
+ enableCpuMemArena?: boolean;
30
+ enableMemPattern?: boolean;
31
+ }
32
+
33
+
34
+ /**
35
+ * Get ONNX model paths
36
+ * Returns paths appropriate for the current environment
37
+ */
38
+ export function getOnnxModelPaths(): {
39
+ evaluationModel: string;
40
+ treeModel: string;
41
+ } {
42
+ // Frontend (Vite environment)
43
+ if (isBrowser && typeof import.meta.env !== "undefined") {
44
+ const evaluationModel = import.meta.env.VITE_ONNX_EVALUATION_MODEL;
45
+ const treeModel = import.meta.env.VITE_ONNX_TREE_MODEL;
46
+
47
+ if (!evaluationModel || !treeModel) {
48
+ throw new Error("ONNX model paths not configured. Check VITE_ONNX_EVALUATION_MODEL and VITE_ONNX_TREE_MODEL in .env");
49
+ }
50
+
51
+ return { evaluationModel, treeModel };
52
+ }
53
+
54
+ // Backend/Tools (Node environment)
55
+ if (isNode) {
56
+ const evaluationModel = process.env.ONNX_EVALUATION_MODEL;
57
+ const treeModel = process.env.ONNX_TREE_MODEL;
58
+
59
+ if (!evaluationModel || !treeModel) {
60
+ throw new Error("ONNX model paths not configured. Check ONNX_EVALUATION_MODEL and ONNX_TREE_MODEL in .env");
61
+ }
62
+
63
+ return { evaluationModel, treeModel };
64
+ }
65
+
66
+ // Should not reach here
67
+ throw new Error("Unknown environment - cannot determine model paths");
68
+ }
69
+
70
+
71
+ /**
72
+ * Get ONNX session options from environment variables
73
+ * Returns session options with threading and optimization configuration
74
+ */
75
+ export function getOnnxSessionOptions(): OnnxSessionOptions {
76
+ // Default values
77
+ const options: OnnxSessionOptions = {
78
+ executionProviders: ["cpu"],
79
+ graphOptimizationLevel: "all",
80
+ enableCpuMemArena: true,
81
+ enableMemPattern: true
82
+ };
83
+
84
+ // Frontend (Vite environment)
85
+ if (isBrowser && typeof import.meta.env !== "undefined") {
86
+ const intraThreadsEnv = import.meta.env.VITE_ONNX_INTRA_OP_NUM_THREADS;
87
+ const interThreadsEnv = import.meta.env.VITE_ONNX_INTER_OP_NUM_THREADS;
88
+ const graphOptEnv = import.meta.env.VITE_ONNX_GRAPH_OPTIMIZATION_LEVEL;
89
+
90
+ if (intraThreadsEnv) options.intraOpNumThreads = parseInt(intraThreadsEnv, 10);
91
+ if (interThreadsEnv) options.interOpNumThreads = parseInt(interThreadsEnv, 10);
92
+ if (graphOptEnv) options.graphOptimizationLevel = graphOptEnv as any;
93
+ }
94
+
95
+ // Backend/Tools (Node environment)
96
+ if (isNode) {
97
+ const intraThreadsEnv = process.env.ONNX_INTRA_OP_NUM_THREADS;
98
+ const interThreadsEnv = process.env.ONNX_INTER_OP_NUM_THREADS;
99
+ const graphOptEnv = process.env.ONNX_GRAPH_OPTIMIZATION_LEVEL;
100
+ const cpuMemArenaEnv = process.env.ONNX_ENABLE_CPU_MEM_ARENA;
101
+ const memPatternEnv = process.env.ONNX_ENABLE_MEM_PATTERN;
102
+
103
+ if (intraThreadsEnv) options.intraOpNumThreads = parseInt(intraThreadsEnv, 10);
104
+ if (interThreadsEnv) options.interOpNumThreads = parseInt(interThreadsEnv, 10);
105
+ if (graphOptEnv) options.graphOptimizationLevel = graphOptEnv as any;
106
+ if (cpuMemArenaEnv) options.enableCpuMemArena = cpuMemArenaEnv === "true";
107
+ if (memPatternEnv) options.enableMemPattern = memPatternEnv === "true";
108
+ }
109
+
110
+ return options;
111
+ }
112
+
113
+
114
+ /**
115
+ * Get absolute path to model file (Node.js only)
116
+ * Resolves relative paths to absolute paths from project root
117
+ */
118
+ export function getAbsoluteModelPath(relativePath: string): string {
119
+ if (!isNode) {
120
+ return relativePath; // Browser environment - return as-is
121
+ }
122
+
123
+ // If already absolute, return as-is
124
+ if (path.isAbsolute(relativePath)) {
125
+ return relativePath;
126
+ }
127
+
128
+ // Resolve relative to project root
129
+ const __filename = fileURLToPath(import.meta.url);
130
+ const __dirname = path.dirname(__filename);
131
+ const rootDir = path.resolve(__dirname, "..");
132
+ return path.resolve(rootDir, relativePath);
133
+ }
134
+
135
+
136
+ /**
137
+ * Load environment variables from .env file (Node.js only)
138
+ * Call this at the start of tool scripts
139
+ *
140
+ * Loading order:
141
+ * 1. .env (base configuration, committed to git)
142
+ * 2. .env.local (local overrides, not committed to git)
143
+ */
144
+ export async function loadEnvConfig(): Promise<void> {
145
+ if (!isNode) {
146
+ console.warn("[Config] loadEnvConfig() called in non-Node environment");
147
+ return;
148
+ }
149
+
150
+ try {
151
+ // Dynamically import dotenv (ESM-compatible)
152
+ const dotenv = (await import("dotenv")).default;
153
+ const __filename = fileURLToPath(import.meta.url);
154
+ const __dirname = path.dirname(__filename);
155
+ const rootDir = path.resolve(__dirname, "..");
156
+
157
+ // Load base .env file
158
+ const baseResult = dotenv.config({ path: path.join(rootDir, ".env") });
159
+
160
+ if (baseResult.error) {
161
+ console.warn("[Config] Failed to load .env:", baseResult.error.message);
162
+ } else {
163
+ console.log("[Config] ✓ Base environment variables loaded from .env");
164
+ }
165
+
166
+ // Load .env.local for local overrides (if exists)
167
+ // Use override: true to allow .env.local to override .env values
168
+ const localResult = dotenv.config({
169
+ path: path.join(rootDir, ".env.local"),
170
+ override: true
171
+ });
172
+
173
+ if (!localResult.error) {
174
+ console.log("[Config] ✓ Local overrides loaded from .env.local");
175
+ }
176
+ // Silently ignore if .env.local doesn't exist (optional file)
177
+
178
+ } catch (error) {
179
+ console.warn("[Config] dotenv not available:", error);
180
+ }
181
+ }
trigo-web/inc/mctsAgent.ts ADDED
@@ -0,0 +1,855 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Monte Carlo Tree Search (MCTS) Agent for Trigo
3
+ *
4
+ * Implements AlphaGo Zero-style MCTS with:
5
+ * - PUCT (Polynomial Upper Confidence Trees) selection
6
+ * - Neural network guidance for policy and value
7
+ * - Visit count statistics for training data generation
8
+ *
9
+ * Based on: Silver et al., "Mastering the Game of Go without Human Knowledge"
10
+ */
11
+
12
+ import { TrigoGame } from "./trigo/game";
13
+ import type { Move } from "./trigo/types";
14
+ import { TrigoTreeAgent } from "./trigoTreeAgent";
15
+ import { TrigoEvaluationAgent } from "./trigoEvaluationAgent";
16
+
17
+
18
+ /**
19
+ * MCTS Configuration
20
+ */
21
+ export interface MCTSConfig {
22
+ numSimulations: number; // Number of MCTS simulations per move (default: 600)
23
+ cPuct: number; // PUCT exploration constant (default: 1.0)
24
+ temperature: number; // Selection temperature for first 30 moves (default: 1.0)
25
+ dirichletAlpha: number; // Dirichlet noise alpha parameter (default: 0.03)
26
+ dirichletEpsilon: number; // Dirichlet noise mixing weight (default: 0.25)
27
+ }
28
+
29
+
30
+ /**
31
+ * MCTS Tree Node
32
+ * Stores search statistics for all legal actions from a given game state
33
+ *
34
+ * Memory optimization: Only root node stores the full game state.
35
+ * Non-root nodes only store the action that led to them.
36
+ * During simulation, a working state is cloned once and mutated along the path.
37
+ */
38
+ interface MCTSNode {
39
+ state: TrigoGame | null; // Game state (only stored at root node for memory efficiency)
40
+ parent: MCTSNode | null; // Parent node (null for root)
41
+ action: Move | null; // Action that led to this node (null for root)
42
+
43
+ // MCTS statistics per action (action key -> value)
44
+ N: Map<string, number>; // Visit counts N(s,a)
45
+ W: Map<string, number>; // Total action-value W(s,a)
46
+ Q: Map<string, number>; // Mean action-value Q(s,a) = W(s,a) / N(s,a)
47
+ P: Map<string, number>; // Prior probabilities P(s,a) from policy network
48
+
49
+ children: Map<string, MCTSNode>; // Child nodes (action key -> child node)
50
+ expanded: boolean; // Whether this node has been expanded
51
+ terminalValue: number | null; // Cached terminal value (null if not terminal or not computed)
52
+
53
+ // Terminal propagation optimization (GPT-5.1 suggestions)
54
+ depth: number; // Distance from root (0 for root)
55
+ playerToMove: number; // Player to move at this node (1=Black, 2=White)
56
+ }
57
+
58
+
59
+ /**
60
+ * MCTS Agent
61
+ * Combines tree search with neural network evaluation
62
+ */
63
+ export class MCTSAgent {
64
+ private treeAgent: TrigoTreeAgent; // For policy priors
65
+ private evaluationAgent: TrigoEvaluationAgent; // For value evaluation
66
+ private config: MCTSConfig;
67
+ public debugMode: boolean = false; // Enable debug logging
68
+
69
+
70
+ constructor(
71
+ treeAgent: TrigoTreeAgent,
72
+ evaluationAgent: TrigoEvaluationAgent,
73
+ config: Partial<MCTSConfig> = {}
74
+ ) {
75
+ this.treeAgent = treeAgent;
76
+ this.evaluationAgent = evaluationAgent;
77
+
78
+ // Default configuration (AlphaGo Zero-inspired)
79
+ this.config = {
80
+ numSimulations: config.numSimulations ?? 600,
81
+ cPuct: config.cPuct ?? 1.0,
82
+ temperature: config.temperature ?? 1.0,
83
+ dirichletAlpha: config.dirichletAlpha ?? 0.03,
84
+ dirichletEpsilon: config.dirichletEpsilon ?? 0.25
85
+ };
86
+ }
87
+
88
+
89
+ /**
90
+ * Select best move using MCTS
91
+ *
92
+ * @param game Current game state
93
+ * @param moveNumber Move number (for temperature schedule)
94
+ * @returns Selected move with visit count statistics
95
+ */
96
+ async selectMove(game: TrigoGame, moveNumber: number): Promise<{
97
+ move: Move;
98
+ visitCounts: Map<string, number>;
99
+ searchPolicy: Map<string, number>; // Normalized visit counts π(a|s)
100
+ rootValue: number;
101
+ }> {
102
+ // Create root node
103
+ const root = this.createNode(game, null, null);
104
+
105
+ // Check if root is already terminal (game over)
106
+ const terminalResult = this.checkTerminal(game);
107
+ if (terminalResult !== null) {
108
+ const currentPlayer = game.getCurrentPlayer();
109
+ return {
110
+ move: { player: currentPlayer === 1 ? "black" : "white", isPass: true },
111
+ visitCounts: new Map(),
112
+ searchPolicy: new Map(),
113
+ rootValue: terminalResult
114
+ };
115
+ }
116
+
117
+ // Run MCTS simulations
118
+ for (let i = 0; i < this.config.numSimulations; i++) {
119
+ await this.runSimulation(root, i);
120
+ }
121
+
122
+ // Temperature schedule: τ=1 for first 30 moves, τ→0 afterward
123
+ const temperature = moveNumber < 30 ? this.config.temperature : 0.01;
124
+
125
+ // Select move based on visit counts
126
+ const move = this.selectPlayAction(root, temperature);
127
+
128
+ // Set correct player for returned move
129
+ const currentPlayer = game.getCurrentPlayer();
130
+ move.player = currentPlayer === 1 ? "black" : "white";
131
+
132
+ // Compute search policy (normalized visit counts)
133
+ const searchPolicy = this.computeSearchPolicy(root, temperature);
134
+
135
+ // Get root value estimate (average Q-value weighted by visit counts)
136
+ const rootValue = this.getRootValue(root);
137
+
138
+ return {
139
+ move,
140
+ visitCounts: new Map(root.N),
141
+ searchPolicy,
142
+ rootValue
143
+ };
144
+ }
145
+
146
+
147
+ /**
148
+ * Run a single MCTS simulation
149
+ * Select -> Expand & Evaluate -> Backup
150
+ *
151
+ * Memory optimization: Clone state once at start, mutate along path.
152
+ * This reduces memory from O(nodes) to O(simulations).
153
+ */
154
+ private async runSimulation(root: MCTSNode, simIndex?: number): Promise<void> {
155
+ // Invariant: root node must always have a non-null state
156
+ if (!root.state) {
157
+ throw new Error("runSimulation: root node must have a non-null state");
158
+ }
159
+
160
+ // Clone root state once for this simulation
161
+ const workingState = root.state.clone();
162
+
163
+ // 1. Selection: Traverse tree using PUCT until reaching unexpanded node
164
+ const { node, path } = this.select(root, workingState);
165
+
166
+ // 2. Expand and Evaluate: Get value from neural network
167
+ const value = await this.expandAndEvaluate(node, workingState);
168
+
169
+ // Debug logging
170
+ if (this.debugMode && simIndex !== undefined && simIndex < 10) {
171
+ const pathStr = path.map(p => p.actionKey).join(" → ");
172
+ const terminalStr = node.terminalValue !== null ? " [TERMINAL]" : "";
173
+ console.log(`Sim ${simIndex + 1}: ${pathStr || "(root)"} → value=${value.toFixed(4)}${terminalStr}`);
174
+ }
175
+
176
+ // 3. Backup: Propagate value up the tree
177
+ this.backup(path, value);
178
+ }
179
+
180
+
181
+ /**
182
+ * Selection phase: Traverse tree using PUCT
183
+ *
184
+ * @param root Root node to start selection from
185
+ * @param workingState Mutable game state that gets updated along the path
186
+ * @returns Leaf node and path taken
187
+ */
188
+ private select(root: MCTSNode, workingState: TrigoGame): {
189
+ node: MCTSNode;
190
+ path: Array<{ node: MCTSNode; actionKey: string }>;
191
+ } {
192
+ const path: Array<{ node: MCTSNode; actionKey: string }> = [];
193
+ let node = root;
194
+
195
+ // Traverse until we reach an unexpanded node
196
+ while (node.expanded) {
197
+ // GPT-5.1 recommendation: Stop at terminal nodes immediately
198
+ // Terminal nodes should not be expanded or evaluated further
199
+ if (node.terminalValue !== null) {
200
+ break; // Return terminal node, use its cached value
201
+ }
202
+
203
+ // Get all legal actions
204
+ const actionKeys = Array.from(node.P.keys());
205
+
206
+ // Terminal node check: if expanded but no actions, this is a terminal node
207
+ if (actionKeys.length === 0) {
208
+ break; // Return this terminal node as leaf
209
+ }
210
+
211
+ // Select action with best PUCT value
212
+ // Both players select HIGHEST PUCT value:
213
+ // - Black: PUCT = -Q + U, max PUCT = max(-Q) = min(Q) ✓
214
+ // - White: PUCT = Q + U, max PUCT = max(Q) ✓
215
+ const currentPlayer = workingState.getCurrentPlayer();
216
+ const isWhite = currentPlayer === 2;
217
+
218
+ let bestActionKey = actionKeys[0];
219
+ let bestPuct = this.calculatePUCT(node, bestActionKey, isWhite);
220
+
221
+ for (let i = 1; i < actionKeys.length; i++) {
222
+ const actionKey = actionKeys[i];
223
+ const puct = this.calculatePUCT(node, actionKey, isWhite);
224
+
225
+ if (puct > bestPuct) {
226
+ bestPuct = puct;
227
+ bestActionKey = actionKey;
228
+ }
229
+ }
230
+
231
+ // Record path
232
+ path.push({ node, actionKey: bestActionKey });
233
+
234
+ // Apply action to working state (instead of cloning)
235
+ const action = this.decodeAction(bestActionKey);
236
+ if (action.isPass) {
237
+ workingState.pass();
238
+ } else if (action.x !== undefined && action.y !== undefined && action.z !== undefined) {
239
+ workingState.drop({ x: action.x, y: action.y, z: action.z });
240
+ }
241
+
242
+ // Move to child (create if doesn't exist)
243
+ if (!node.children.has(bestActionKey)) {
244
+ // Create child node WITHOUT storing state (memory optimization)
245
+ const childNode = this.createNode(null, node, action);
246
+ node.children.set(bestActionKey, childNode);
247
+ }
248
+
249
+ node = node.children.get(bestActionKey)!;
250
+ }
251
+
252
+ return { node, path };
253
+ }
254
+
255
+
256
+ /**
257
+ * Expand and evaluate leaf node using neural networks
258
+ *
259
+ * @param node Leaf node to expand
260
+ * @param workingState Current game state at this node (passed from simulation)
261
+ * @returns Value estimate from evaluation network
262
+ */
263
+ private async expandAndEvaluate(node: MCTSNode, workingState: TrigoGame): Promise<number> {
264
+ // Check if terminal value is already cached
265
+ if (node.terminalValue !== null) {
266
+ return node.terminalValue;
267
+ }
268
+
269
+ // Check if game is over (terminal state)
270
+ const terminalValue = this.checkTerminal(workingState);
271
+ if (terminalValue !== null) {
272
+ // Mark terminal node as expanded with empty action set to prevent revisits
273
+ // Cache the terminal value to avoid repeated checks
274
+ node.expanded = true;
275
+ node.terminalValue = terminalValue;
276
+ node.P = new Map(); // No actions available (terminal)
277
+ node.N = new Map();
278
+ node.W = new Map();
279
+ node.Q = new Map();
280
+ node.children = new Map();
281
+
282
+ return terminalValue;
283
+ }
284
+
285
+ // Non-terminal state: expand with policy network and evaluate
286
+ // Get all valid moves
287
+ const currentPlayer = workingState.getCurrentPlayer() === 1 ? "black" : "white";
288
+ const validPositions = workingState.validMovePositions();
289
+ const moves: Move[] = validPositions.map(pos => ({
290
+ x: pos.x,
291
+ y: pos.y,
292
+ z: pos.z,
293
+ player: currentPlayer
294
+ }));
295
+ moves.push({ player: currentPlayer, isPass: true });
296
+
297
+ // Get policy priors from tree agent
298
+ const scoredMoves = await this.treeAgent.scoreMoves(workingState, moves);
299
+
300
+ // Convert log probabilities to probabilities and normalize (stable softmax)
301
+ const maxScore = Math.max(...scoredMoves.map(m => m.score));
302
+ const expScores = scoredMoves.map(m => Math.exp(m.score - maxScore));
303
+ const sumExp = expScores.reduce((sum, exp) => sum + exp, 0);
304
+
305
+ // Initialize priors P(s,a)
306
+ node.P = new Map();
307
+ node.N = new Map();
308
+ node.W = new Map();
309
+ node.Q = new Map();
310
+
311
+ // Handle edge case: if all scores are -Infinity or sumExp is 0/NaN
312
+ const useFallback = !isFinite(sumExp) || sumExp < 1e-10;
313
+
314
+ for (let i = 0; i < scoredMoves.length; i++) {
315
+ const actionKey = this.encodeAction(scoredMoves[i].move);
316
+
317
+ // Use uniform distribution as fallback if normalization fails
318
+ const prior = useFallback ? (1.0 / scoredMoves.length) : (expScores[i] / sumExp);
319
+
320
+ node.P.set(actionKey, prior);
321
+ node.N.set(actionKey, 0);
322
+ node.W.set(actionKey, 0);
323
+ node.Q.set(actionKey, 0);
324
+ }
325
+
326
+ // Add Dirichlet noise at root
327
+ if (node.parent === null) {
328
+ this.addDirichletNoise(node.P);
329
+ }
330
+
331
+ // Mark as expanded
332
+ node.expanded = true;
333
+
334
+ // Get value estimate from evaluation agent
335
+ const evaluation = await this.evaluationAgent.evaluatePosition(workingState);
336
+
337
+ // Return value directly (value model returns white-positive by design)
338
+ return evaluation.value;
339
+ }
340
+
341
+
342
+ /**
343
+ * Backup phase: Propagate value up the tree
344
+ *
345
+ * White-positive minimax propagation:
346
+ * - All Q-values represent White's advantage (positive = White winning)
347
+ * - When all children are terminal, mark parent as terminal with minimax value:
348
+ * * White's turn: terminal_value = max(children terminal values)
349
+ * * Black's turn: terminal_value = min(children terminal values)
350
+ *
351
+ * Improvements (based on GPT-5.1 review):
352
+ * - Uses stored playerToMove instead of computing from depth
353
+ * - Uses stored depth instead of recomputing via parent walk
354
+ *
355
+ * @param path Path from root to leaf
356
+ * @param value Value to propagate (white-positive: positive = white winning)
357
+ */
358
+ private backup(path: Array<{ node: MCTSNode; actionKey: string }>, value: number): void {
359
+ // Propagate value up the tree (white-positive throughout)
360
+ // No sign flipping needed - Q values are always white-positive
361
+ for (let i = path.length - 1; i >= 0; i--) {
362
+ const { node, actionKey } = path[i];
363
+
364
+ // Update statistics
365
+ const n = node.N.get(actionKey) ?? 0;
366
+ const w = node.W.get(actionKey) ?? 0;
367
+
368
+ node.N.set(actionKey, n + 1);
369
+ node.W.set(actionKey, w + value);
370
+ node.Q.set(actionKey, (w + value) / (n + 1));
371
+
372
+ // ========== Terminal State Propagation ==========
373
+ // Check if this node should be marked as terminal
374
+ // Condition: node is fully expanded AND all children are terminal AND node itself not yet marked
375
+ if (node.expanded && node.terminalValue === null) {
376
+ const actionKeys = Array.from(node.P.keys());
377
+
378
+ // Skip propagation if no actions (already a terminal leaf, or error state)
379
+ if (actionKeys.length === 0) {
380
+ continue;
381
+ }
382
+
383
+ // Check if ALL children are terminal
384
+ let allChildrenTerminal = true;
385
+ const childTerminalValues: number[] = [];
386
+
387
+ for (const key of actionKeys) {
388
+ const child = node.children.get(key);
389
+
390
+ // If child doesn't exist yet, not all children explored
391
+ if (!child) {
392
+ allChildrenTerminal = false;
393
+ break;
394
+ }
395
+
396
+ // If child is not terminal, not all children terminal
397
+ if (child.terminalValue === null) {
398
+ allChildrenTerminal = false;
399
+ break;
400
+ }
401
+
402
+ // Child is terminal, collect its value
403
+ childTerminalValues.push(child.terminalValue);
404
+ }
405
+
406
+ // If all children are terminal, mark current node as terminal with minimax value
407
+ if (allChildrenTerminal && childTerminalValues.length > 0) {
408
+ // Use stored playerToMove instead of computing from depth (GPT-5.1 suggestion)
409
+ const isWhiteTurn = node.playerToMove === 2; // 2 = White, 1 = Black
410
+
411
+ // Apply minimax: choose best child value from current player's perspective
412
+ let terminalValue: number;
413
+
414
+ if (isWhiteTurn) {
415
+ // White maximizes Q-value (white-positive)
416
+ terminalValue = Math.max(...childTerminalValues);
417
+ } else {
418
+ // Black minimizes Q-value (white-positive)
419
+ terminalValue = Math.min(...childTerminalValues);
420
+ }
421
+
422
+ // Mark this node as terminal with the minimax value
423
+ node.terminalValue = terminalValue;
424
+
425
+ // Debug logging for terminal propagation
426
+ if (this.debugMode) {
427
+ const playerName = isWhiteTurn ? 'White' : 'Black';
428
+ console.log(
429
+ `[Terminal Propagation] Node at depth ${node.depth} (${playerName}) marked terminal: ` +
430
+ `value=${terminalValue.toFixed(4)}, children=[${childTerminalValues.map(v => v.toFixed(2)).join(', ')}]`
431
+ );
432
+ }
433
+ }
434
+ }
435
+ // ================================================
436
+ }
437
+ }
438
+
439
+
440
+ /**
441
+ * Calculate PUCT value for action selection
442
+ *
443
+ * PUCT = Q(s,a) + U(s,a) [for White, who maximizes]
444
+ * PUCT = -Q(s,a) + U(s,a) [for Black, who minimizes]
445
+ * where U(s,a) = c_puct * P(s,a) * sqrt(Σ_b N(s,b)) / (1 + N(s,a))
446
+ *
447
+ * @param node Current node
448
+ * @param actionKey Action to evaluate
449
+ * @param isWhite Whether current player is White
450
+ * @returns PUCT value
451
+ */
452
+ private calculatePUCT(node: MCTSNode, actionKey: string, isWhite: boolean): number {
453
+ const Q = node.Q.get(actionKey) ?? 0;
454
+ const N = node.N.get(actionKey) ?? 0;
455
+ const P = node.P.get(actionKey) ?? 0;
456
+
457
+ // Sum of all visit counts at this node
458
+ const totalN = Array.from(node.N.values()).reduce((sum, n) => sum + n, 0);
459
+
460
+ // Exploration term: U(s,a) = c_puct * P(s,a) * sqrt(Σ_b N(s,b) + 1) / (1 + N(s,a))
461
+ // +1 in sqrt to avoid zero exploration when node first expanded
462
+ const U = this.config.cPuct * P * Math.sqrt(totalN + 1) / (1 + N);
463
+
464
+ // Black minimizes Q (flips sign), White maximizes Q
465
+ return (isWhite ? Q : -Q) + U;
466
+ }
467
+
468
+
469
+ /**
470
+ * Select action to play based on visit counts
471
+ * Uses temperature to control exploration vs exploitation
472
+ *
473
+ * @param node Root node
474
+ * @param temperature Selection temperature (τ=1 for exploration, τ→0 for greedy)
475
+ * @returns Selected move
476
+ */
477
+ private selectPlayAction(node: MCTSNode, temperature: number): Move {
478
+ const actionKeys = Array.from(node.N.keys());
479
+
480
+ // Edge case: no actions available (unexpanded root or terminal state)
481
+ if (actionKeys.length === 0) {
482
+ // Fallback to priors if available
483
+ const priorKeys = Array.from(node.P.keys());
484
+ if (priorKeys.length > 0) {
485
+ // Sample from prior distribution
486
+ const priors = priorKeys.map(key => node.P.get(key) ?? 0);
487
+ const sumP = priors.reduce((sum, p) => sum + p, 0);
488
+ if (sumP > 0) {
489
+ let rand = Math.random() * sumP;
490
+ for (let i = 0; i < priorKeys.length; i++) {
491
+ rand -= priors[i];
492
+ if (rand <= 0) {
493
+ return this.decodeAction(priorKeys[i]);
494
+ }
495
+ }
496
+ return this.decodeAction(priorKeys[priorKeys.length - 1]);
497
+ }
498
+ // Uniform fallback
499
+ const randomIndex = Math.floor(Math.random() * priorKeys.length);
500
+ return this.decodeAction(priorKeys[randomIndex]);
501
+ }
502
+ // No actions at all - return Pass as last resort
503
+ return { player: "black", isPass: true };
504
+ }
505
+
506
+ if (temperature < 0.01) {
507
+ // Greedy: Select action with highest visit count
508
+ let bestActionKey = actionKeys[0];
509
+ let bestN = node.N.get(bestActionKey) ?? 0;
510
+
511
+ for (let i = 1; i < actionKeys.length; i++) {
512
+ const actionKey = actionKeys[i];
513
+ const n = node.N.get(actionKey) ?? 0;
514
+ if (n > bestN) {
515
+ bestN = n;
516
+ bestActionKey = actionKey;
517
+ }
518
+ }
519
+
520
+ return this.decodeAction(bestActionKey);
521
+ } else {
522
+ // Temperature-based sampling: π(a|s) ∝ N(s,a)^(1/τ)
523
+ const nValues = actionKeys.map(key => node.N.get(key) ?? 0);
524
+ const nPowered = nValues.map(n => Math.pow(n, 1 / temperature));
525
+ const sumN = nPowered.reduce((sum, n) => sum + n, 0);
526
+
527
+ // Handle edge case: if all visits are 0 or sum is invalid
528
+ if (!isFinite(sumN) || sumN <= 0) {
529
+ // Fallback to uniform random selection (or use priors)
530
+ const randomIndex = Math.floor(Math.random() * actionKeys.length);
531
+ return this.decodeAction(actionKeys[randomIndex]);
532
+ }
533
+
534
+ // Sample from distribution
535
+ let rand = Math.random() * sumN;
536
+ for (let i = 0; i < actionKeys.length; i++) {
537
+ rand -= nPowered[i];
538
+ if (rand <= 0) {
539
+ return this.decodeAction(actionKeys[i]);
540
+ }
541
+ }
542
+
543
+ // Fallback (shouldn't reach here due to floating point precision)
544
+ return this.decodeAction(actionKeys[actionKeys.length - 1]);
545
+ }
546
+ }
547
+
548
+
549
+ /**
550
+ * Compute search policy from visit counts
551
+ * π(a|s) = N(s,a)^(1/τ) / Σ_b N(s,b)^(1/τ)
552
+ *
553
+ * @param node Root node
554
+ * @param temperature Selection temperature
555
+ * @returns Normalized policy distribution
556
+ */
557
+ private computeSearchPolicy(node: MCTSNode, temperature: number): Map<string, number> {
558
+ const policy = new Map<string, number>();
559
+ const actionKeys = Array.from(node.N.keys());
560
+
561
+ // Compute π(a|s) ∝ N(s,a)^(1/τ)
562
+ const nPowered = actionKeys.map(key => Math.pow(node.N.get(key) ?? 0, 1 / temperature));
563
+ const sumN = nPowered.reduce((sum, n) => sum + n, 0);
564
+
565
+ // Handle edge case: if all visits are 0 or sum is invalid
566
+ if (!isFinite(sumN) || sumN <= 0) {
567
+ // Fallback to uniform distribution
568
+ const uniform = 1 / actionKeys.length;
569
+ for (const key of actionKeys) {
570
+ policy.set(key, uniform);
571
+ }
572
+ return policy;
573
+ }
574
+
575
+ for (let i = 0; i < actionKeys.length; i++) {
576
+ const actionKey = actionKeys[i];
577
+ policy.set(actionKey, nPowered[i] / sumN);
578
+ }
579
+
580
+ return policy;
581
+ }
582
+
583
+
584
+ /**
585
+ * Get root value estimate (weighted average of Q-values)
586
+ */
587
+ private getRootValue(node: MCTSNode): number {
588
+ const actionKeys = Array.from(node.N.keys());
589
+ const totalN = Array.from(node.N.values()).reduce((sum, n) => sum + n, 0);
590
+
591
+ if (totalN === 0) {
592
+ return 0;
593
+ }
594
+
595
+ let weightedSum = 0;
596
+ for (const actionKey of actionKeys) {
597
+ const q = node.Q.get(actionKey) ?? 0;
598
+ const n = node.N.get(actionKey) ?? 0;
599
+ weightedSum += q * n;
600
+ }
601
+
602
+ return weightedSum / totalN;
603
+ }
604
+
605
+
606
+ /**
607
+ * Add Dirichlet noise to prior probabilities at root
608
+ * P(s,a) = (1 - ε) * p_a + ε * η_a
609
+ * where η ~ Dir(α)
610
+ *
611
+ * Note: Pass move is excluded from noise to prevent exploration of
612
+ * clearly suboptimal opening passes.
613
+ */
614
+ private addDirichletNoise(priors: Map<string, number>): void {
615
+ // Exclude Pass from Dirichlet noise - it should not be explored at root
616
+ const actionKeys = Array.from(priors.keys()).filter(key => key !== "pass");
617
+ const alpha = this.config.dirichletAlpha;
618
+ const epsilon = this.config.dirichletEpsilon;
619
+
620
+ // If only Pass is available, no noise to add
621
+ if (actionKeys.length === 0) {
622
+ return;
623
+ }
624
+
625
+ // Generate Dirichlet noise (simplified using Gamma distribution)
626
+ const noise: number[] = [];
627
+ let noiseSum = 0;
628
+
629
+ for (let i = 0; i < actionKeys.length; i++) {
630
+ // Gamma(α, 1) approximation using rejection sampling
631
+ const sample = this.sampleGamma(alpha);
632
+ noise.push(sample);
633
+ noiseSum += sample;
634
+ }
635
+
636
+ // Handle edge case: if all Gamma samples are 0 (extremely unlikely but possible)
637
+ if (!isFinite(noiseSum) || noiseSum <= 0) {
638
+ // Fallback: use uniform noise (no mixing, keep original priors)
639
+ return;
640
+ }
641
+
642
+ // Normalize and mix with priors (only for non-Pass actions)
643
+ for (let i = 0; i < actionKeys.length; i++) {
644
+ const actionKey = actionKeys[i];
645
+ const prior = priors.get(actionKey) ?? 0;
646
+ const noiseFraction = noise[i] / noiseSum;
647
+ priors.set(actionKey, (1 - epsilon) * prior + epsilon * noiseFraction);
648
+ }
649
+ }
650
+
651
+
652
+ /**
653
+ * Sample from Gamma distribution using Marsaglia and Tsang method (2000)
654
+ * Used for Dirichlet noise generation
655
+ */
656
+ private sampleGamma(alpha: number): number {
657
+ if (alpha <= 0) {
658
+ throw new Error("Gamma distribution alpha must be > 0");
659
+ }
660
+
661
+ // For alpha < 1, use transformation: sample Gamma(alpha+1) then multiply by U^(1/alpha)
662
+ if (alpha < 1) {
663
+ const u = Math.random();
664
+ const g = this.sampleGamma(alpha + 1);
665
+ return g * Math.pow(u, 1 / alpha);
666
+ }
667
+
668
+ // For alpha >= 1, use Marsaglia and Tsang's method
669
+ const d = alpha - 1/3;
670
+ const c = 1 / Math.sqrt(9 * d);
671
+
672
+ while (true) {
673
+ let x, v;
674
+ do {
675
+ x = this.randomNormal();
676
+ v = 1 + c * x;
677
+ } while (v <= 0);
678
+
679
+ v = v * v * v;
680
+ const u = Math.random();
681
+
682
+ // Fast acceptance check
683
+ if (u < 1 - 0.0331 * x * x * x * x) {
684
+ return d * v;
685
+ }
686
+
687
+ // Fallback acceptance check
688
+ if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) {
689
+ return d * v;
690
+ }
691
+ }
692
+ }
693
+
694
+
695
+ /**
696
+ * Sample from standard normal distribution (Box-Muller transform)
697
+ */
698
+ private randomNormal(): number {
699
+ const u1 = Math.random();
700
+ const u2 = Math.random();
701
+ return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
702
+ }
703
+
704
+
705
+ /**
706
+ * Check if game state is terminal and return value if so
707
+ *
708
+ * Terminal conditions (checked in order of cost):
709
+ * 1. Game already finished (double-pass or resignation) - CHEAPEST
710
+ * 2. Board coverage > 50% AND naturally terminal (calls isNaturallyTerminal) - EXPENSIVE
711
+ *
712
+ * NOTE: The coverage check (> 50%) is an optimization to avoid expensive
713
+ * territory calculations on sparse boards where natural termination is unlikely.
714
+ *
715
+ * @param state Game state to check
716
+ * @returns Terminal value (white-positive) if terminal, null otherwise
717
+ */
718
+ private checkTerminal(state: TrigoGame): number | null {
719
+ // 1. Check if game is already finished (double-pass, resignation, etc.)
720
+ // This is the cheapest check - just reading a status flag
721
+ if (state.getGameStatus() === "finished") {
722
+ const territory = state.getTerritory();
723
+ return this.calculateTerminalValue(territory);
724
+ }
725
+
726
+ // 2. Check for "natural" game end (all territory claimed, no capturing moves)
727
+ // Optimization: Only check if board is reasonably full (> 50% coverage)
728
+ // because natural termination is unlikely on sparse boards
729
+ const board = state.getBoard();
730
+ const shape = state.getShape();
731
+ const totalPositions = shape.x * shape.y * shape.z;
732
+
733
+ // Count stones (cheap)
734
+ let stoneCount = 0;
735
+
736
+ for (let x = 0; x < shape.x; x++) {
737
+ for (let y = 0; y < shape.y; y++) {
738
+ for (let z = 0; z < shape.z; z++) {
739
+ const stone = board[x][y][z];
740
+ if (stone === 1 || stone === 2) { // StoneType.BLACK or WHITE
741
+ stoneCount++;
742
+ }
743
+ }
744
+ }
745
+ }
746
+
747
+ const coverageRatio = stoneCount / totalPositions;
748
+
749
+ // Only check for natural termination if board is reasonably full
750
+ if (coverageRatio > 0.5) {
751
+ if (state.isNaturallyTerminal()) {
752
+ const territory = state.getTerritory();
753
+ return this.calculateTerminalValue(territory);
754
+ }
755
+ }
756
+
757
+
758
+ return null; // Not terminal
759
+ }
760
+
761
+
762
+ /**
763
+ * Calculate terminal value from territory scores
764
+ * Uses logarithmic scaling matching the training code
765
+ *
766
+ * @param territory Territory counts from game
767
+ * @returns Value (white-positive: positive = white winning)
768
+ */
769
+ private calculateTerminalValue(territory: { black: number; white: number; neutral: number }): number {
770
+ const scoreDiff = territory.white - territory.black;
771
+
772
+ if (Math.abs(scoreDiff) < 1e-6) {
773
+ // Draw/tie case
774
+ return 0.0;
775
+ }
776
+
777
+ // Match training formula from valueCausalLoss.py:_expand_value_targets
778
+ // target = sign(score) * (1 + log(|score|)) * territory_value_factor
779
+ // The log term incentivizes winning by larger margins (logarithmically)
780
+ const territory_value_factor = 1.0; // Default from training config
781
+ const signScore = Math.sign(scoreDiff);
782
+ return signScore * (1 + Math.log(Math.abs(scoreDiff))) * territory_value_factor;
783
+ }
784
+
785
+
786
+ /**
787
+ * Create a new MCTS node
788
+ *
789
+ * @param state Game state (only provided for root node, null for others to save memory)
790
+ * @param parent Parent node
791
+ * @param action Action that led to this node
792
+ * @param playerToMove Player to move at this node (derived from state if available)
793
+ */
794
+ private createNode(state: TrigoGame | null, parent: MCTSNode | null, action: Move | null, playerToMove?: number): MCTSNode {
795
+ // Determine player to move
796
+ let player: number;
797
+ if (playerToMove !== undefined) {
798
+ player = playerToMove;
799
+ } else if (state) {
800
+ // Most reliable: derive from actual game state
801
+ player = state.getCurrentPlayer();
802
+ } else if (parent) {
803
+ // NOTE: Fallback assumes strictly alternating turns (no passes keeping same player)
804
+ // For standard Go-like games with strict alternation, this is safe.
805
+ player = parent.playerToMove === 1 ? 2 : 1;
806
+ } else {
807
+ // Default to Black for root if no info
808
+ player = 1;
809
+ }
810
+
811
+ return {
812
+ state,
813
+ parent,
814
+ action,
815
+ N: new Map(),
816
+ W: new Map(),
817
+ Q: new Map(),
818
+ P: new Map(),
819
+ children: new Map(),
820
+ expanded: false,
821
+ terminalValue: null,
822
+ depth: parent ? parent.depth + 1 : 0,
823
+ playerToMove: player
824
+ };
825
+ }
826
+
827
+
828
+ /**
829
+ * Encode move to string key for storage in maps
830
+ * Note: Only encodes position, player info is handled separately
831
+ */
832
+ private encodeAction(move: Move): string {
833
+ if (move.isPass) {
834
+ return "pass";
835
+ }
836
+ return `${move.x},${move.y},${move.z}`;
837
+ }
838
+
839
+
840
+ /**
841
+ * Decode string key back to move
842
+ * Note: Returns move with placeholder player - caller must set correct player
843
+ * based on game state before using the move externally
844
+ */
845
+ private decodeAction(key: string): Move {
846
+ if (key === "pass") {
847
+ // Player is placeholder - will be set by caller (selectMove sets it from game state)
848
+ return { player: "black", isPass: true };
849
+ }
850
+
851
+ const [x, y, z] = key.split(",").map(Number);
852
+ // Player is placeholder - will be set by caller (selectMove sets it from game state)
853
+ return { player: "black", x, y, z };
854
+ }
855
+ }
trigo-web/inc/modelInferencer.ts CHANGED
@@ -6,6 +6,15 @@
6
  *
7
  * Adapted from Node.js test_inference.js for cross-platform use
8
  * Provides causal language model inference using GPT-2 ONNX model
 
 
 
 
 
 
 
 
 
9
  */
10
 
11
  /**
@@ -82,15 +91,20 @@ export class ModelInferencer {
82
  private config: InferencerConfig;
83
  private TensorClass: TensorConstructor;
84
 
85
- // TGN tokenizer: byte-level (0-255) + PAD(256) + START(257) + END(258)
86
- private readonly PAD_TOKEN = 256;
87
- private readonly START_TOKEN = 257;
88
- private readonly END_TOKEN = 258;
 
 
 
 
 
89
 
90
  constructor(TensorClass: TensorConstructor, config: Partial<InferencerConfig> = {}) {
91
  this.TensorClass = TensorClass;
92
  this.config = {
93
- vocabSize: 259,
94
  seqLen: 256,
95
  ...config
96
  };
@@ -342,6 +356,47 @@ export class ModelInferencer {
342
  }
343
 
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  /**
346
  * Compute softmax for a single position's logits
347
  * @param logits - Full logits array
 
6
  *
7
  * Adapted from Node.js test_inference.js for cross-platform use
8
  * Provides causal language model inference using GPT-2 ONNX model
9
+ *
10
+ * Vocabulary Design (128 tokens):
11
+ * 0-3: Special tokens (PAD=0, START=1, END=2, VALUE=3)
12
+ * 4-7: Reserved for future use
13
+ * 10: LF (newline) for multi-line game records
14
+ * 32-127: ASCII printable characters (direct identity mapping)
15
+ *
16
+ * This design uses direct identity mapping: token_id = ascii_value
17
+ * No complex formulas needed - simple and efficient.
18
  */
19
 
20
  /**
 
91
  private config: InferencerConfig;
92
  private TensorClass: TensorConstructor;
93
 
94
+ // TGN tokenizer: Compact 128-token vocabulary with direct ASCII mapping
95
+ // 0-3: Special tokens (PAD, START, END, VALUE)
96
+ // 4-7: Reserved for future use
97
+ // 10: Newline (LF)
98
+ // 32-127: ASCII printable characters (direct identity mapping)
99
+ private readonly PAD_TOKEN = 0;
100
+ private readonly START_TOKEN = 1;
101
+ private readonly END_TOKEN = 2;
102
+ private readonly VALUE_TOKEN = 3;
103
 
104
  constructor(TensorClass: TensorConstructor, config: Partial<InferencerConfig> = {}) {
105
  this.TensorClass = TensorClass;
106
  this.config = {
107
+ vocabSize: config.vocabSize || 128, // Allow override via config
108
  seqLen: 256,
109
  ...config
110
  };
 
356
  }
357
 
358
 
359
+ /**
360
+ * Run value prediction inference (for evaluation mode models)
361
+ * For models exported with --evaluation-mode flag
362
+ * @param tokens - Token IDs (already includes START/END tokens and padding)
363
+ * @returns Predicted game outcome value in range [-1, 1]
364
+ */
365
+ async runValuePrediction(tokens: number[]): Promise<number> {
366
+ if (!this.session) {
367
+ throw new Error("Inferencer not initialized. Call setSession() first.");
368
+ }
369
+
370
+ const seqLen = tokens.length;
371
+
372
+ // Convert to BigInt64Array for ONNX int64 tensors
373
+ const inputIds = new BigInt64Array(seqLen);
374
+ for (let i = 0; i < seqLen; i++) {
375
+ inputIds[i] = BigInt(tokens[i]);
376
+ }
377
+
378
+ // Create input tensor [1, seq_len]
379
+ const inputTensor = new this.TensorClass("int64", inputIds, [1, seqLen]);
380
+
381
+ // Run inference
382
+ const results = await this.session.run({
383
+ input_ids: inputTensor
384
+ });
385
+
386
+ // Extract value
387
+ // Output shape: [batch_size] = [1]
388
+ // For evaluation models, output name is "values" not "logits"
389
+ const values = results.values;
390
+ if (!values) {
391
+ throw new Error("Evaluation model did not return 'values' output. Check model export.");
392
+ }
393
+
394
+ const predictedValue = values.data[0] as number;
395
+
396
+ return predictedValue;
397
+ }
398
+
399
+
400
  /**
401
  * Compute softmax for a single position's logits
402
  * @param logits - Full logits array
trigo-web/inc/trigo/game.ts CHANGED
@@ -105,9 +105,10 @@ export class TrigoGame {
105
  // Last captured stones for Ko rule detection
106
  private lastCapturedPositions: Position[] | null = null;
107
 
108
- // Territory cache
109
- private territoryDirty: boolean = true;
110
  private cachedTerritory: TerritoryResult | null = null;
 
111
 
112
  /**
113
  * Constructor
@@ -152,13 +153,22 @@ export class TrigoGame {
152
  this.stepHistory = [];
153
  this.currentStepIndex = 0;
154
  this.lastCapturedPositions = null;
155
- this.territoryDirty = true;
156
- this.cachedTerritory = null;
157
  this.gameStatus = "idle";
158
  this.gameResult = undefined;
159
  this.passCount = 0;
160
  }
161
 
 
 
 
 
 
 
 
 
 
 
162
  /**
163
  * Clone the game state (deep copy)
164
  * Creates an independent copy with all state preserved
@@ -197,9 +207,8 @@ export class TrigoGame {
197
  };
198
  }
199
 
200
- // Territory cache will be recalculated on demand
201
- cloned.territoryDirty = true;
202
- cloned.cachedTerritory = null;
203
 
204
  return cloned;
205
  }
@@ -386,6 +395,115 @@ export class TrigoGame {
386
  return validPositions;
387
  }
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  /**
390
  * Reset pass count (called when a stone is placed)
391
  */
@@ -419,8 +537,8 @@ export class TrigoGame {
419
  // Store captured positions for Ko rule
420
  this.lastCapturedPositions = capturedPositions.length > 0 ? capturedPositions : null;
421
 
422
- // Mark territory as dirty
423
- this.territoryDirty = true;
424
 
425
  // Reset pass count when a stone is placed
426
  this.resetPassCount();
@@ -442,7 +560,7 @@ export class TrigoGame {
442
  this.callbacks.onCapture(capturedPositions);
443
  }
444
 
445
- if (this.territoryDirty && this.callbacks.onTerritoryChange) {
446
  this.callbacks.onTerritoryChange(this.getTerritory());
447
  }
448
 
@@ -584,8 +702,8 @@ export class TrigoGame {
584
  this.lastCapturedPositions = null;
585
  }
586
 
587
- // Mark territory as dirty
588
- this.territoryDirty = true;
589
 
590
  // Trigger callback
591
  if (this.callbacks.onStepBack) {
@@ -631,8 +749,8 @@ export class TrigoGame {
631
  this.currentStepIndex++;
632
  this.currentPlayer = getEnemyColor(nextStep.player); // Switch to next player
633
 
634
- // Mark territory as dirty
635
- this.territoryDirty = true;
636
 
637
  // Trigger callback
638
  if (this.callbacks.onStepAdvance) {
@@ -718,8 +836,8 @@ export class TrigoGame {
718
  // Recalculate pass count based on new history position
719
  this.recalculatePassCount();
720
 
721
- // Mark territory as dirty
722
- this.territoryDirty = true;
723
 
724
  // Trigger callback based on direction
725
  if (index < oldStepIndex && this.callbacks.onStepBack) {
@@ -763,10 +881,9 @@ export class TrigoGame {
763
  * Returns cached result if territory hasn't changed
764
  */
765
  getTerritory(): TerritoryResult {
766
- if (this.territoryDirty || !this.cachedTerritory) {
767
  this.cachedTerritory = calculateTerritory(this.board, this.shape);
768
- this.territoryDirty = false;
769
- }
770
  return this.cachedTerritory;
771
  }
772
 
@@ -842,8 +959,7 @@ export class TrigoGame {
842
  this.lastCapturedPositions = null;
843
  }
844
 
845
- this.territoryDirty = true;
846
- this.cachedTerritory = null;
847
 
848
  return true;
849
  } catch (error) {
@@ -987,7 +1103,7 @@ export class TrigoGame {
987
  timeControl?: string;
988
  application?: string;
989
  [key: string]: string | undefined;
990
- }): string {
991
  const lines: string[] = [];
992
 
993
  // Add metadata tags
@@ -1020,7 +1136,7 @@ export class TrigoGame {
1020
  resultStr += "Resign";
1021
  }
1022
 
1023
- lines.push(`[Result "${resultStr}"]`);
1024
  }
1025
 
1026
  // Add board size (without quotes - parser expects unquoted board shape)
@@ -1097,6 +1213,16 @@ export class TrigoGame {
1097
  lines.push(currentLine);
1098
  }
1099
 
 
 
 
 
 
 
 
 
 
 
1100
  // Add empty line at the end
1101
  lines.push("");
1102
 
 
105
  // Last captured stones for Ko rule detection
106
  private lastCapturedPositions: Position[] | null = null;
107
 
108
+ // Static analysis cache (territory, capturing moves)
109
+ // Invalidated on any board state change
110
  private cachedTerritory: TerritoryResult | null = null;
111
+ private cachedCapturingMove: Map<Stone, boolean> = new Map();
112
 
113
  /**
114
  * Constructor
 
153
  this.stepHistory = [];
154
  this.currentStepIndex = 0;
155
  this.lastCapturedPositions = null;
156
+ this.invalidateAnalysisCache();
 
157
  this.gameStatus = "idle";
158
  this.gameResult = undefined;
159
  this.passCount = 0;
160
  }
161
 
162
+
163
+ /**
164
+ * Invalidate all static analysis caches
165
+ * Called when board state changes
166
+ */
167
+ private invalidateAnalysisCache(): void {
168
+ this.cachedTerritory = null;
169
+ this.cachedCapturingMove.clear();
170
+ }
171
+
172
  /**
173
  * Clone the game state (deep copy)
174
  * Creates an independent copy with all state preserved
 
207
  };
208
  }
209
 
210
+ // Analysis cache will be recalculated on demand
211
+ cloned.invalidateAnalysisCache();
 
212
 
213
  return cloned;
214
  }
 
395
  return validPositions;
396
  }
397
 
398
+
399
+ /**
400
+ * Check if any valid move can capture enemy stones
401
+ * Used by MCTS to determine if a position is truly terminal
402
+ *
403
+ * Results are cached and invalidated when board state changes.
404
+ *
405
+ * @param player - Optional player color (defaults to current player)
406
+ * @returns true if at least one valid move would capture stones
407
+ */
408
+ hasCapturingMove(player?: Stone): boolean {
409
+ const playerColor = player ?? this.currentPlayer;
410
+
411
+ // Check cache first
412
+ if (this.cachedCapturingMove.has(playerColor)) {
413
+ return this.cachedCapturingMove.get(playerColor)!;
414
+ }
415
+
416
+ // Compute and cache the result
417
+ const result = this.computeHasCapturingMove(playerColor);
418
+ this.cachedCapturingMove.set(playerColor, result);
419
+
420
+ return result;
421
+ }
422
+
423
+
424
+ /**
425
+ * Internal: Compute whether a player has any capturing move
426
+ */
427
+ private computeHasCapturingMove(playerColor: Stone): boolean {
428
+ // Iterate through all board positions
429
+ for (let x = 0; x < this.shape.x; x++) {
430
+ for (let y = 0; y < this.shape.y; y++) {
431
+ for (let z = 0; z < this.shape.z; z++) {
432
+ // Skip occupied positions
433
+ if (this.board[x][y][z] !== StoneType.EMPTY) {
434
+ continue;
435
+ }
436
+
437
+ const pos: Position = { x, y, z };
438
+
439
+ // Skip invalid moves (Ko, suicide)
440
+ if (
441
+ isKoViolation(
442
+ pos,
443
+ playerColor,
444
+ this.board,
445
+ this.shape,
446
+ this.lastCapturedPositions
447
+ )
448
+ ) {
449
+ continue;
450
+ }
451
+
452
+ if (isSuicideMove(pos, playerColor, this.board, this.shape)) {
453
+ continue;
454
+ }
455
+
456
+ // Check if this move would capture any stones
457
+ const capturedGroups = findCapturedGroups(pos, playerColor, this.board, this.shape);
458
+ if (capturedGroups.length > 0) {
459
+ return true; // Found a capturing move
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ return false; // No capturing moves available
466
+ }
467
+
468
+
469
+ /**
470
+ * Check if the game has reached a natural terminal state
471
+ *
472
+ * A game is naturally terminal when:
473
+ * - All territory is claimed (neutral === 0)
474
+ * - AND neither player has any capturing moves available
475
+ *
476
+ * This is different from the "finished" status which requires double pass.
477
+ * Natural termination means the game state is completely settled and
478
+ * no further moves can meaningfully change the outcome.
479
+ *
480
+ * NOTE: This method is expensive due to territory calculation and capture move checking.
481
+ * Use coverage ratio check as a pre-filter when calling frequently.
482
+ *
483
+ * Used by:
484
+ * - MCTS agent (terminal detection for tree search)
485
+ * - Model battle (early stopping when settled)
486
+ * - Self-play games (early stopping when settled)
487
+ *
488
+ * @returns true if the game is naturally terminal, false otherwise
489
+ */
490
+ isNaturallyTerminal(): boolean {
491
+ // Check if all territory is claimed (uses cached territory)
492
+ const territory = this.getTerritory();
493
+ if (territory.neutral !== 0) {
494
+ return false; // Still has neutral territory - not terminal
495
+ }
496
+
497
+ // All territory claimed - check if capturing moves are possible
498
+ // IMPORTANT: Check even if one player has no stones - they might still capture!
499
+ const blackCanCapture = this.hasCapturingMove(StoneType.BLACK);
500
+ const whiteCanCapture = this.hasCapturingMove(StoneType.WHITE);
501
+
502
+ // Terminal only if neither player can capture
503
+ return !blackCanCapture && !whiteCanCapture;
504
+ }
505
+
506
+
507
  /**
508
  * Reset pass count (called when a stone is placed)
509
  */
 
537
  // Store captured positions for Ko rule
538
  this.lastCapturedPositions = capturedPositions.length > 0 ? capturedPositions : null;
539
 
540
+ // Invalidate analysis cache
541
+ this.invalidateAnalysisCache();
542
 
543
  // Reset pass count when a stone is placed
544
  this.resetPassCount();
 
560
  this.callbacks.onCapture(capturedPositions);
561
  }
562
 
563
+ if (this.callbacks.onTerritoryChange) {
564
  this.callbacks.onTerritoryChange(this.getTerritory());
565
  }
566
 
 
702
  this.lastCapturedPositions = null;
703
  }
704
 
705
+ // Invalidate analysis cache
706
+ this.invalidateAnalysisCache();
707
 
708
  // Trigger callback
709
  if (this.callbacks.onStepBack) {
 
749
  this.currentStepIndex++;
750
  this.currentPlayer = getEnemyColor(nextStep.player); // Switch to next player
751
 
752
+ // Invalidate analysis cache
753
+ this.invalidateAnalysisCache();
754
 
755
  // Trigger callback
756
  if (this.callbacks.onStepAdvance) {
 
836
  // Recalculate pass count based on new history position
837
  this.recalculatePassCount();
838
 
839
+ // Invalidate analysis cache
840
+ this.invalidateAnalysisCache();
841
 
842
  // Trigger callback based on direction
843
  if (index < oldStepIndex && this.callbacks.onStepBack) {
 
881
  * Returns cached result if territory hasn't changed
882
  */
883
  getTerritory(): TerritoryResult {
884
+ if (!this.cachedTerritory)
885
  this.cachedTerritory = calculateTerritory(this.board, this.shape);
886
+
 
887
  return this.cachedTerritory;
888
  }
889
 
 
959
  this.lastCapturedPositions = null;
960
  }
961
 
962
+ this.invalidateAnalysisCache();
 
963
 
964
  return true;
965
  } catch (error) {
 
1103
  timeControl?: string;
1104
  application?: string;
1105
  [key: string]: string | undefined;
1106
+ }, {markResult}: {markResult?: boolean} = {}): string {
1107
  const lines: string[] = [];
1108
 
1109
  // Add metadata tags
 
1136
  resultStr += "Resign";
1137
  }
1138
 
1139
+ //lines.push(`[Result "${resultStr}"]`);
1140
  }
1141
 
1142
  // Add board size (without quotes - parser expects unquoted board shape)
 
1213
  lines.push(currentLine);
1214
  }
1215
 
1216
+ // Add score result if game has finished
1217
+ if (markResult) {
1218
+ // Calculate territory to get final score
1219
+ const territory = this.getTerritory();
1220
+ const scoreDiff = territory.black - territory.white;
1221
+
1222
+ // Add score comment: negative means black wins, positive means white wins
1223
+ lines.push(`; ${scoreDiff > 0 ? '-' : scoreDiff < 0 ? '+' : ''}${Math.abs(scoreDiff)}`);
1224
+ }
1225
+
1226
  // Add empty line at the end
1227
  lines.push("");
1228
 
trigo-web/inc/trigoAgent.ts CHANGED
@@ -61,7 +61,9 @@ export class TrigoAgent {
61
  }
62
 
63
  /**
64
- * Convert string to token IDs (byte-level encoding)
 
 
65
  */
66
  private stringToTokens(text: string): number[] {
67
  return Array.from(text).map((char) => char.charCodeAt(0));
 
61
  }
62
 
63
  /**
64
+ * Convert string to token IDs (byte-level encoding with ASCII direct mapping)
65
+ * For characters 32-127, token_id = ascii_value
66
+ * Special tokens (0-3) and newline (10) are handled by the tokenizer
67
  */
68
  private stringToTokens(text: string): number[] {
69
  return Array.from(text).map((char) => char.charCodeAt(0));
trigo-web/inc/trigoEvaluationAgent.ts ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Trigo Evaluation Agent - AI agent using value prediction for position evaluation
3
+ *
4
+ * Uses evaluation mode ONNX model to predict game outcomes.
5
+ * The model takes a TGN sequence and returns a value in [-1, 1]:
6
+ * - Positive values favor White (second player)
7
+ * - Negative values favor Black (first player)
8
+ * - Values near ±1 indicate strong advantage
9
+ * - Values near 0 indicate balanced position
10
+ */
11
+
12
+ import { ModelInferencer } from "./modelInferencer";
13
+ import { TrigoGame, StoneType } from "./trigo/game";
14
+ import type { Move, Stone, Position } from "./trigo/types";
15
+ import { encodeAb0yz } from "./trigo/ab0yz";
16
+
17
+ export interface ValuedMove {
18
+ move: Move;
19
+ value: number; // Position value in [-1, 1] after this move
20
+ notation: string; // TGN notation
21
+ }
22
+
23
+ export interface PositionEvaluation {
24
+ value: number; // Current position value
25
+ interpretation: string; // Human-readable interpretation
26
+ }
27
+
28
+ export class TrigoEvaluationAgent {
29
+ private inferencer: ModelInferencer;
30
+ private readonly START_TOKEN = 1;
31
+ private readonly END_TOKEN = 2;
32
+ private readonly VALUE_TOKEN = 3;
33
+
34
+ constructor(inferencer: ModelInferencer) {
35
+ this.inferencer = inferencer;
36
+ }
37
+
38
+ /**
39
+ * Check if agent is initialized (checks if inferencer has a session)
40
+ */
41
+ isInitialized(): boolean {
42
+ return this.inferencer !== null && this.inferencer.isReady();
43
+ }
44
+
45
+ /**
46
+ * Convert Stone type to player string
47
+ */
48
+ private stoneToPlayer(stone: Stone): "black" | "white" {
49
+ if (stone === StoneType.BLACK) return "black";
50
+ if (stone === StoneType.WHITE) return "white";
51
+ throw new Error(`Invalid stone type: ${stone}`);
52
+ }
53
+
54
+ /**
55
+ * Encode a position to TGN notation
56
+ */
57
+ private positionToTGN(pos: Position, shape: { x: number; y: number; z: number }): string {
58
+ const posArray = [pos.x, pos.y, pos.z];
59
+ const shapeArray = [shape.x, shape.y, shape.z];
60
+ return encodeAb0yz(posArray, shapeArray);
61
+ }
62
+
63
+ /**
64
+ * Convert string to byte tokens (ASCII encoding)
65
+ */
66
+ private stringToTokens(str: string): number[] {
67
+ const tokens: number[] = [];
68
+ for (let i = 0; i < str.length; i++) {
69
+ const charCode = str.charCodeAt(i);
70
+ // Only accept valid ASCII tokens (0-127)
71
+ if (charCode < 128) {
72
+ tokens.push(charCode);
73
+ }
74
+ }
75
+ return tokens;
76
+ }
77
+
78
+ /**
79
+ * Tokenize TGN text with special tokens
80
+ * Adds START and END tokens for proper sequence formatting
81
+ */
82
+ private tokenizeTGN(tgnText: string, maxLength: number = 256): number[] {
83
+ // Convert TGN to tokens
84
+ const contentTokens = this.stringToTokens(tgnText);
85
+
86
+ // Add special tokens: START + content + END
87
+ const tokens = [this.START_TOKEN, ...contentTokens, this.END_TOKEN];
88
+
89
+ // Truncate if needed (preserve END token)
90
+ if (tokens.length > maxLength) {
91
+ return [...tokens.slice(0, maxLength - 1), this.END_TOKEN];
92
+ }
93
+
94
+ // Pad with PAD tokens (0) to fixed length
95
+ while (tokens.length < maxLength) {
96
+ tokens.push(0); // PAD_TOKEN
97
+ }
98
+
99
+ return tokens;
100
+ }
101
+
102
+ /**
103
+ * Evaluate a position by predicting the game outcome value
104
+ * @param game - The game to evaluate
105
+ * @returns Position evaluation with value and interpretation
106
+ */
107
+ async evaluatePosition(game: TrigoGame): Promise<PositionEvaluation> {
108
+ if (!this.isInitialized()) {
109
+ throw new Error("Agent not initialized. Inferencer must have a session.");
110
+ }
111
+
112
+ // Get current TGN
113
+ const tgn = game.toTGN().trim();
114
+
115
+ // Tokenize
116
+ const config = this.inferencer.getConfig();
117
+ const tokens = this.tokenizeTGN(tgn, config.seqLen);
118
+
119
+ // Run value prediction inference
120
+ const value = await this.inferencer.runValuePrediction(tokens);
121
+
122
+ return {
123
+ value,
124
+ interpretation: this.interpretValue(value)
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Interpret a position value to human-readable text
130
+ */
131
+ private interpretValue(value: number): string {
132
+ if (value > 0.5) {
133
+ return `Strong advantage for White (+${value.toFixed(3)})`;
134
+ } else if (value > 0.1) {
135
+ return `Slight advantage for White (+${value.toFixed(3)})`;
136
+ } else if (value > -0.1) {
137
+ return `Balanced position (${value.toFixed(3)})`;
138
+ } else if (value > -0.5) {
139
+ return `Slight advantage for Black (${value.toFixed(3)})`;
140
+ } else {
141
+ return `Strong advantage for Black (${value.toFixed(3)})`;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Evaluate all valid moves and return them sorted by value
147
+ * @param game - Current game state
148
+ * @returns Array of moves with their position values
149
+ */
150
+ async evaluateMoves(game: TrigoGame): Promise<ValuedMove[]> {
151
+ if (!this.isInitialized()) {
152
+ throw new Error("Agent not initialized");
153
+ }
154
+
155
+ const currentPlayer = this.stoneToPlayer(game.getCurrentPlayer());
156
+
157
+ // Get all valid moves
158
+ const validMoves: Move[] = game.validMovePositions().map((pos) => ({
159
+ x: pos.x,
160
+ y: pos.y,
161
+ z: pos.z,
162
+ player: currentPlayer
163
+ }));
164
+ validMoves.push({ player: currentPlayer, isPass: true }); // Add pass move
165
+
166
+ if (validMoves.length === 0) {
167
+ return [];
168
+ }
169
+
170
+ console.log(`[TrigoEvaluationAgent] Evaluating ${validMoves.length} moves...`);
171
+
172
+ // Evaluate each move by simulating it and evaluating the resulting position
173
+ const valuedMoves: ValuedMove[] = [];
174
+
175
+ for (const move of validMoves) {
176
+ // Clone game and apply move
177
+ const testGame = game.clone();
178
+ let success: boolean;
179
+
180
+ if (move.isPass) {
181
+ success = testGame.pass();
182
+ } else if (move.x !== undefined && move.y !== undefined && move.z !== undefined) {
183
+ success = testGame.drop({ x: move.x, y: move.y, z: move.z });
184
+ } else {
185
+ continue; // Invalid move format
186
+ }
187
+
188
+ if (!success) {
189
+ continue; // Move failed
190
+ }
191
+
192
+ // Evaluate the resulting position
193
+ const evaluation = await this.evaluatePosition(testGame);
194
+
195
+ // Get move notation
196
+ const notation = move.isPass
197
+ ? "P"
198
+ : this.positionToTGN(
199
+ { x: move.x!, y: move.y!, z: move.z! },
200
+ testGame.getShape()
201
+ );
202
+
203
+ // Model returns white-positive values (positive = white advantage)
204
+ // Adjust to current player perspective: positive = advantage for current player
205
+ const adjustedValue = currentPlayer === "white" ? evaluation.value : -evaluation.value;
206
+
207
+ valuedMoves.push({
208
+ move,
209
+ value: adjustedValue,
210
+ notation
211
+ });
212
+ }
213
+
214
+ // Sort by value (highest first - best for current player)
215
+ valuedMoves.sort((a, b) => b.value - a.value);
216
+
217
+ return valuedMoves;
218
+ }
219
+
220
+ /**
221
+ * Select the best move based on position evaluation
222
+ * @param game - Current game state
223
+ * @returns Best move or null if no moves available
224
+ */
225
+ async selectBestMove(game: TrigoGame): Promise<Move | null> {
226
+ const valuedMoves = await this.evaluateMoves(game);
227
+
228
+ if (valuedMoves.length === 0) {
229
+ return null;
230
+ }
231
+
232
+ console.log(`[TrigoEvaluationAgent] Best move: ${valuedMoves[0].notation} (value: ${valuedMoves[0].value.toFixed(4)})`);
233
+ console.log(`[TrigoEvaluationAgent] Top 3 moves:`);
234
+ for (let i = 0; i < Math.min(3, valuedMoves.length); i++) {
235
+ console.log(` ${i + 1}. ${valuedMoves[i].notation}: ${valuedMoves[i].value.toFixed(4)}`);
236
+ }
237
+
238
+ return valuedMoves[0].move;
239
+ }
240
+
241
+ /**
242
+ * Get detailed evaluation report for current position
243
+ */
244
+ async getEvaluationReport(game: TrigoGame): Promise<{
245
+ currentValue: PositionEvaluation;
246
+ topMoves: ValuedMove[];
247
+ moveCount: number;
248
+ }> {
249
+ const currentEval = await this.evaluatePosition(game);
250
+ const valuedMoves = await this.evaluateMoves(game);
251
+
252
+ return {
253
+ currentValue: currentEval,
254
+ topMoves: valuedMoves.slice(0, 5), // Top 5 moves
255
+ moveCount: valuedMoves.length
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Clean up resources
261
+ */
262
+ destroy(): void {
263
+ // Agent doesn't own the inferencer, so just clear reference
264
+ console.log("[TrigoEvaluationAgent] Destroyed");
265
+ }
266
+ }
trigo-web/inc/trigoTreeAgent.ts CHANGED
@@ -20,6 +20,9 @@ export interface ScoredMove {
20
  export class TrigoTreeAgent {
21
  private inferencer: ModelInferencer;
22
 
 
 
 
23
  constructor(inferencer: ModelInferencer) {
24
  this.inferencer = inferencer;
25
  }
@@ -78,6 +81,7 @@ export class TrigoTreeAgent {
78
  evaluatedIds: number[];
79
  mask: number[];
80
  moveToLeafPos: number[];
 
81
  } {
82
  type Seq = { moveIndex: number; tokens: number[] };
83
 
@@ -154,6 +158,9 @@ export class TrigoTreeAgent {
154
  }
155
  for (const r of roots) dfs(r);
156
 
 
 
 
157
  // --- Build ancestor mask ---
158
  const mask = new Array(total * total).fill(0);
159
  for (let i = 0; i < total; i++) {
@@ -164,7 +171,7 @@ export class TrigoTreeAgent {
164
  }
165
  }
166
 
167
- return { evaluatedIds, mask, moveToLeafPos };
168
  }
169
 
170
  /**
@@ -178,7 +185,8 @@ export class TrigoTreeAgent {
178
  prefixTokens: number[];
179
  evaluatedIds: number[];
180
  mask: number[];
181
- moveData: Array<{ move: Move; notation: string; leafPos: number; parentPos: number }>;
 
182
  } {
183
  // Get current TGN as prefix
184
  const currentTGN = game.toTGN().trim();
@@ -206,7 +214,7 @@ export class TrigoTreeAgent {
206
  prefix = currentTGN + " ";
207
  }
208
 
209
- const prefixTokens = this.stringToTokens(prefix);
210
 
211
  // Encode each move to tokens (only first 2 tokens)
212
  const shape = game.getShape();
@@ -221,6 +229,8 @@ export class TrigoTreeAgent {
221
  }
222
 
223
  // Exclude the last token
 
 
224
  const fullTokens = this.stringToTokens(notation);
225
  const tokens = fullTokens.slice(0, fullTokens.length - 1);
226
 
@@ -229,38 +239,19 @@ export class TrigoTreeAgent {
229
 
230
  // Build prefix tree
231
  const tokenArrays = movesWithTokens.map((m) => m.tokens);
232
- const { evaluatedIds, mask, moveToLeafPos } = this.buildPrefixTree(tokenArrays);
233
 
234
- // Build move data with leaf positions and parent positions
235
  const moveData = movesWithTokens.map((m, index) => {
236
  const leafPos = moveToLeafPos[index];
237
- // Find parent position (root position for this move)
238
- // Parent is the first token position
239
- const firstToken = m.tokens[0];
240
- let parentPos = -1;
241
- for (let i = 0; i < evaluatedIds.length; i++) {
242
- if (evaluatedIds[i] === firstToken && i < leafPos) {
243
- // This is a potential parent
244
- // Check if it's in the same branch by checking mask
245
- // If leafPos can see position i, then i might be the parent
246
- if (mask[leafPos * evaluatedIds.length + i] === 1.0 && i !== leafPos) {
247
- // Find the closest parent (maximum index less than leafPos that leaf can see)
248
- if (i > parentPos) {
249
- parentPos = i;
250
- }
251
- }
252
- }
253
- }
254
-
255
  return {
256
  move: m.move,
257
  notation: m.notation,
258
- leafPos,
259
- parentPos
260
  };
261
  });
262
 
263
- return { prefixTokens, evaluatedIds, mask, moveData };
264
  }
265
 
266
  /**
@@ -272,16 +263,19 @@ export class TrigoTreeAgent {
272
  ): {
273
  evaluatedIds: number[];
274
  mask: number[];
275
- moveData: Array<{ move: Move; notation: string; leafPos: number; parentPos: number }>;
 
276
  } {
277
  return this.buildMoveTree(game, moves);
278
  }
279
 
280
  /**
281
- * Select best move using tree attention
282
- * Evaluates all valid moves in a single inference call
 
 
283
  */
284
- async selectBestMove(game: TrigoGame): Promise<Move | null> {
285
  if (!this.inferencer.isReady()) {
286
  throw new Error("Inferencer not initialized");
287
  }
@@ -289,29 +283,76 @@ export class TrigoTreeAgent {
289
  // Get current player as string
290
  const currentPlayer = this.stoneToPlayer(game.getCurrentPlayer());
291
 
292
- // Get all valid moves
293
  const validMoves: Move[] = game.validMovePositions().map((pos) => ({
294
  x: pos.x,
295
  y: pos.y,
296
  z: pos.z,
297
  player: currentPlayer
298
  }));
299
- validMoves.push({ player: currentPlayer, isPass: true }); // Add pass move
300
 
 
301
  if (validMoves.length === 0) {
302
- return null;
303
  }
304
 
305
- // Score all moves using tree attention
306
  const scoredMoves = await this.scoreMoves(game, validMoves);
307
 
308
- // Return move with highest score
309
  if (scoredMoves.length === 0) {
310
- return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  }
312
 
313
- scoredMoves.sort((a, b) => b.score - a.score);
314
- return scoredMoves[0].move;
 
 
 
 
 
 
 
 
 
 
 
315
  }
316
 
317
  /**
@@ -323,13 +364,13 @@ export class TrigoTreeAgent {
323
  }
324
 
325
  // Build tree structure
326
- const { prefixTokens, evaluatedIds, mask, moveData } = this.buildMoveTree(game, moves);
327
 
328
- console.debug(`Tree structure: ${evaluatedIds.length} nodes for ${moveData.length} moves`);
329
- console.debug(`Evaluated IDs:`, evaluatedIds.map((id) => String.fromCharCode(id)).join(""));
330
  //console.debug(
331
  // `Move positions:`,
332
- // moveData.map((m) => `${m.notation}@${m.leafPos}(parent=${m.parentPos})`)
333
  //);
334
 
335
  // Prepare inputs for evaluation
@@ -343,10 +384,14 @@ export class TrigoTreeAgent {
343
  const output = await this.inferencer.runEvaluationInference(inputs);
344
  const { logits, numEvaluated } = output;
345
 
346
- console.debug(`Inference output: ${numEvaluated} evaluated positions`);
 
 
 
 
347
 
348
- // Score each move by accumulating log probabilities for all tokens in the path
349
- // For each move, traverse the full path from root to leaf and sum log probabilities
350
  const scoredMoves: ScoredMove[] = [];
351
 
352
  // Cache softmax results for each output position to avoid recomputation
@@ -361,46 +406,122 @@ export class TrigoTreeAgent {
361
  for (const data of moveData) {
362
  let logProb = 0;
363
 
364
- // Reconstruct the full path from root to leaf using the mask
365
- // The mask tells us which positions each position can attend to (ancestors)
366
- // We need to find all positions from root (or first move token) to leaf
367
- const leafPos = data.leafPos;
368
- const path: number[] = [0];
369
-
370
- // Build path by finding all ancestors that this leaf can see
371
- // Start from position 0 and find all positions up to leafPos that are in the path
372
- for (let pos = 0; pos <= leafPos; pos++) {
373
- // Check if leaf can see this position (it's an ancestor or self)
374
- if (mask[leafPos * evaluatedIds.length + pos] === 1) {
375
- path.push(pos + 1);
 
376
  }
 
 
 
 
 
 
 
377
  }
378
- //console.debug("path:", data.notation, "->", path);
379
 
380
- // Now accumulate log probabilities for all transitions in the path
381
- // For each token in the path, we need P(token[i] | context up to token[i-1])
382
- // The logits at output position j predict the NEXT token after position j
383
- // So to get P(token at position i | context), we look at output from parent position
384
- for (let i = 0; i < path.length; i++) {
385
- const currentPos = path[i];
386
- const currentToken = data.notation.charCodeAt(i);
 
 
 
 
 
387
 
388
- // Subsequent tokens: predicted from previous position
389
- // The output at prevPos predicts the token at currentPos
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
 
391
- console.assert(currentPos <= numEvaluated, `Output position ${currentPos} exceeds numEvaluated ${numEvaluated}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
 
393
- if (currentPos <= numEvaluated) {
394
- const probs = getSoftmax(currentPos);
395
- const prob = probs[currentToken];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
 
397
- if (prob > 0)
398
- logProb += Math.log(prob);
399
- else
400
- logProb += -100;
 
 
 
 
 
 
 
 
 
 
 
 
401
  }
402
- else
403
- logProb += -100;
404
  }
405
 
406
  scoredMoves.push({
 
20
  export class TrigoTreeAgent {
21
  private inferencer: ModelInferencer;
22
 
23
+ // Special token constants (must match TGN tokenizer)
24
+ private readonly START_TOKEN = 1;
25
+
26
  constructor(inferencer: ModelInferencer) {
27
  this.inferencer = inferencer;
28
  }
 
81
  evaluatedIds: number[];
82
  mask: number[];
83
  moveToLeafPos: number[];
84
+ parent: Array<number | null>;
85
  } {
86
  type Seq = { moveIndex: number; tokens: number[] };
87
 
 
158
  }
159
  for (const r of roots) dfs(r);
160
 
161
+ // NOTE: moveToLeafPos[i] = -1 means the move has empty tokens (e.g., single-char notation)
162
+ // In this case, we use prefix logits directly for scoring (valid behavior)
163
+
164
  // --- Build ancestor mask ---
165
  const mask = new Array(total * total).fill(0);
166
  for (let i = 0; i < total; i++) {
 
171
  }
172
  }
173
 
174
+ return { evaluatedIds, mask, moveToLeafPos, parent };
175
  }
176
 
177
  /**
 
185
  prefixTokens: number[];
186
  evaluatedIds: number[];
187
  mask: number[];
188
+ parent: Array<number | null>;
189
+ moveData: Array<{ move: Move; notation: string; leafPos: number }>;
190
  } {
191
  // Get current TGN as prefix
192
  const currentTGN = game.toTGN().trim();
 
214
  prefix = currentTGN + " ";
215
  }
216
 
217
+ const prefixTokens = [this.START_TOKEN, ...this.stringToTokens(prefix)];
218
 
219
  // Encode each move to tokens (only first 2 tokens)
220
  const shape = game.getShape();
 
229
  }
230
 
231
  // Exclude the last token
232
+ // For single-char notations, this results in empty tokens array,
233
+ // which means we use prefix logits directly for scoring
234
  const fullTokens = this.stringToTokens(notation);
235
  const tokens = fullTokens.slice(0, fullTokens.length - 1);
236
 
 
239
 
240
  // Build prefix tree
241
  const tokenArrays = movesWithTokens.map((m) => m.tokens);
242
+ const { evaluatedIds, mask, moveToLeafPos, parent } = this.buildPrefixTree(tokenArrays);
243
 
244
+ // Build move data with leaf positions only
245
  const moveData = movesWithTokens.map((m, index) => {
246
  const leafPos = moveToLeafPos[index];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  return {
248
  move: m.move,
249
  notation: m.notation,
250
+ leafPos
 
251
  };
252
  });
253
 
254
+ return { prefixTokens, evaluatedIds, mask, parent, moveData };
255
  }
256
 
257
  /**
 
263
  ): {
264
  evaluatedIds: number[];
265
  mask: number[];
266
+ parent: Array<number | null>;
267
+ moveData: Array<{ move: Move; notation: string; leafPos: number }>;
268
  } {
269
  return this.buildMoveTree(game, moves);
270
  }
271
 
272
  /**
273
+ * Select move using tree attention with optional temperature sampling
274
+ * @param game Current game state
275
+ * @param temperature Sampling temperature (0 = greedy, higher = more random)
276
+ * @returns Selected move (position or Pass if no valid positions)
277
  */
278
+ async selectMove(game: TrigoGame, temperature: number = 0): Promise<Move> {
279
  if (!this.inferencer.isReady()) {
280
  throw new Error("Inferencer not initialized");
281
  }
 
283
  // Get current player as string
284
  const currentPlayer = this.stoneToPlayer(game.getCurrentPlayer());
285
 
286
+ // Get all valid position moves (excluding Pass)
287
  const validMoves: Move[] = game.validMovePositions().map((pos) => ({
288
  x: pos.x,
289
  y: pos.y,
290
  z: pos.z,
291
  player: currentPlayer
292
  }));
 
293
 
294
+ // If no position moves available, return Pass directly
295
  if (validMoves.length === 0) {
296
+ return { player: currentPlayer, isPass: true };
297
  }
298
 
299
+ // Score only position moves (Pass excluded from inference)
300
  const scoredMoves = await this.scoreMoves(game, validMoves);
301
 
302
+ // Fallback to Pass if scoring fails
303
  if (scoredMoves.length === 0) {
304
+ return { player: currentPlayer, isPass: true };
305
+ }
306
+
307
+ // Select move based on temperature
308
+ if (temperature <= 0.01) {
309
+ // Greedy selection (use reduce to avoid mutating scoredMoves)
310
+ const best = scoredMoves.reduce((a, b) => (b.score > a.score ? b : a));
311
+ return best.move;
312
+ }
313
+
314
+ // Temperature sampling
315
+ return this.sampleMove(scoredMoves, temperature);
316
+ }
317
+
318
+ /**
319
+ * Select best move using tree attention (greedy, temperature=0)
320
+ * Evaluates all valid moves in a single inference call
321
+ * Pass is excluded from model prediction - returned directly if no positions available
322
+ */
323
+ async selectBestMove(game: TrigoGame): Promise<Move> {
324
+ return this.selectMove(game, 0);
325
+ }
326
+
327
+ /**
328
+ * Sample a move from scored moves using temperature
329
+ */
330
+ private sampleMove(scoredMoves: ScoredMove[], temperature: number): Move {
331
+ // Apply temperature scaling to log probabilities
332
+ const adjustedScores = scoredMoves.map((m) => m.score / temperature);
333
+ const maxScore = Math.max(...adjustedScores);
334
+ const expScores = adjustedScores.map((score) => Math.exp(score - maxScore));
335
+ const sumExp = expScores.reduce((sum, exp) => sum + exp, 0);
336
+
337
+ if (sumExp === 0 || !isFinite(sumExp)) {
338
+ // Fallback to uniform random
339
+ const idx = Math.floor(Math.random() * scoredMoves.length);
340
+ return scoredMoves[idx].move;
341
  }
342
 
343
+ const probabilities = expScores.map((exp) => exp / sumExp);
344
+
345
+ // Weighted random sampling
346
+ const random = Math.random();
347
+ let cumulative = 0;
348
+ for (let i = 0; i < scoredMoves.length; i++) {
349
+ cumulative += probabilities[i];
350
+ if (random <= cumulative) {
351
+ return scoredMoves[i].move;
352
+ }
353
+ }
354
+
355
+ return scoredMoves[scoredMoves.length - 1].move;
356
  }
357
 
358
  /**
 
364
  }
365
 
366
  // Build tree structure
367
+ const { prefixTokens, evaluatedIds, mask, parent, moveData } = this.buildMoveTree(game, moves);
368
 
369
+ //console.debug(`Tree structure: ${evaluatedIds.length} nodes for ${moveData.length} moves`);
370
+ //console.debug(`Evaluated IDs:`, evaluatedIds.map((id) => String.fromCharCode(id)).join(""));
371
  //console.debug(
372
  // `Move positions:`,
373
+ // moveData.map((m) => `${m.notation}@${m.leafPos}`)
374
  //);
375
 
376
  // Prepare inputs for evaluation
 
384
  const output = await this.inferencer.runEvaluationInference(inputs);
385
  const { logits, numEvaluated } = output;
386
 
387
+ //console.debug(`Inference output: ${numEvaluated} evaluated positions`);
388
+ //process.stdout.write(".");
389
+
390
+ // Minimum probability threshold to avoid log(0) while preserving small probabilities
391
+ const MIN_PROB = 1e-10; // log(1e-10) ≈ -23
392
 
393
+ // Score each move by accumulating log probabilities along the path
394
+ // For each move, build the path from root to leaf using parent array
395
  const scoredMoves: ScoredMove[] = [];
396
 
397
  // Cache softmax results for each output position to avoid recomputation
 
406
  for (const data of moveData) {
407
  let logProb = 0;
408
 
409
+ // Special case: leafPos = -1 means empty tokens (single-char notation)
410
+ // Use prefix logits directly to predict the single character
411
+ if (data.leafPos === -1) {
412
+ const notationTokens = this.stringToTokens(data.notation);
413
+ if (notationTokens.length === 1) {
414
+ // Single-char notation: use prefix output (logits[0]) to predict it
415
+ const token = notationTokens[0];
416
+ const probs = getSoftmax(0); // Prefix output
417
+ const prob = Math.max(probs[token], MIN_PROB);
418
+ logProb = Math.log(prob);
419
+ } else {
420
+ console.error(`Unexpected: leafPos=-1 but notation length=${notationTokens.length}`);
421
+ logProb = Math.log(MIN_PROB);
422
  }
423
+
424
+ scoredMoves.push({
425
+ move: data.move,
426
+ score: logProb,
427
+ notation: data.notation
428
+ });
429
+ continue; // Skip the normal path processing
430
  }
 
431
 
432
+ // Build path from leaf to root using parent array, then reverse
433
+ const pathReverse: number[] = [];
434
+ let pos: number | null = data.leafPos;
435
+ const visited = new Set<number>();
436
+
437
+ // Safety checks: prevent infinite loops and invalid indices
438
+ while (pos !== null && pos !== undefined) {
439
+ // Check for cycles
440
+ if (visited.has(pos)) {
441
+ console.error(`Cycle detected in parent array at position ${pos}`);
442
+ break;
443
+ }
444
 
445
+ // Check for valid index
446
+ if (pos < 0 || pos >= parent.length) {
447
+ console.error(`Invalid position ${pos}, parent array length: ${parent.length}`);
448
+ break;
449
+ }
450
+
451
+ visited.add(pos);
452
+ pathReverse.push(pos);
453
+ pos = parent[pos];
454
+
455
+ // Safety limit to prevent runaway loops
456
+ if (pathReverse.length > 10000) {
457
+ console.error(`Path too long (>10000), possible infinite loop. leafPos: ${data.leafPos}`);
458
+ break;
459
+ }
460
+ }
461
 
462
+ // Reverse to get root→leaf path (indices in evaluatedIds array)
463
+ const path = pathReverse.reverse();
464
+
465
+ // Now accumulate log probabilities for each transition in path
466
+ // TreeLM returns logits[0..m] where:
467
+ // logits[0] = output at prefix last position (n-1) → predicts evaluatedIds[0]
468
+ // logits[i] = output at position (n-1+i) → predicts evaluatedIds[i]
469
+ //
470
+ // For a parent→child transition:
471
+ // Parent: evaluatedIds[parentIdx] at input position (n+parentIdx)
472
+ // Parent output: at position (n+parentIdx), which is logits[parentIdx+1]
473
+ // Child token: evaluatedIds[childIdx]
474
+ // Probability: softmax(logits[parentIdx+1])[evaluatedIds[childIdx]]
475
+
476
+ // Special case: root token (predicted from prefix last position)
477
+ if (path.length > 0) {
478
+ const rootPos = path[0];
479
+ const rootToken = evaluatedIds[rootPos];
480
+
481
+ // Root is predicted by prefix last position output (logits[0])
482
+ const probs = getSoftmax(0);
483
+ const prob = Math.max(probs[rootToken], MIN_PROB); // Clip to minimum
484
+ logProb += Math.log(prob);
485
+ }
486
 
487
+ // Subsequent transitions: parent→child in tree
488
+ for (let i = 1; i < path.length; i++) {
489
+ const parentPos = path[i - 1]; // evaluatedIds index
490
+ const childPos = path[i]; // evaluatedIds index
491
+ const childToken = evaluatedIds[childPos];
492
+
493
+ // Parent output is at logits[parentPos+1]
494
+ const logitsIndex = parentPos + 1;
495
+
496
+ // Check bounds: logitsIndex must be <= numEvaluated
497
+ // (logits has length numEvaluated+1, indices 0 to numEvaluated)
498
+ if (logitsIndex <= numEvaluated) {
499
+ const probs = getSoftmax(logitsIndex);
500
+ const prob = Math.max(probs[childToken], MIN_PROB); // Clip to minimum
501
+ logProb += Math.log(prob);
502
+ } else {
503
+ // Parent position out of bounds
504
+ logProb += Math.log(MIN_PROB);
505
+ }
506
+ }
507
 
508
+ // CRITICAL: Add probability for the LAST token (excluded from tree)
509
+ // The last character of the move notation was excluded from evaluatedIds
510
+ // We need to predict it using the leaf node's output
511
+ if (path.length > 0) {
512
+ const leafPos = path[path.length - 1]; // Last position in path
513
+ const lastToken = this.stringToTokens(data.notation).pop()!; // Last char of notation
514
+
515
+ // Leaf output is at logits[leafPos+1]
516
+ const logitsIndex = leafPos + 1;
517
+
518
+ if (logitsIndex <= numEvaluated) {
519
+ const probs = getSoftmax(logitsIndex);
520
+ const prob = Math.max(probs[lastToken], MIN_PROB); // Clip to minimum
521
+ logProb += Math.log(prob);
522
+ } else {
523
+ logProb += Math.log(MIN_PROB);
524
  }
 
 
525
  }
526
 
527
  scoredMoves.push({
trigo-web/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
trigo-web/package.json CHANGED
@@ -48,7 +48,7 @@
48
  "jison": "^0.4.18",
49
  "jsdom": "^27.1.0",
50
  "lint-staged": "^16.2.7",
51
- "onnxruntime-node": "1.23.2",
52
  "onnxruntime-web": "1.23.2",
53
  "prettier": "^3.6.2",
54
  "tsx": "^4.20.6",
@@ -58,5 +58,8 @@
58
  "vue": "^3.3.4",
59
  "vue-tsc": "^3.1.3",
60
  "yargs": "^18.0.0"
 
 
 
61
  }
62
  }
 
48
  "jison": "^0.4.18",
49
  "jsdom": "^27.1.0",
50
  "lint-staged": "^16.2.7",
51
+ "onnxruntime-node": "^1.23.2",
52
  "onnxruntime-web": "1.23.2",
53
  "prettier": "^3.6.2",
54
  "tsx": "^4.20.6",
 
58
  "vue": "^3.3.4",
59
  "vue-tsc": "^3.1.3",
60
  "yargs": "^18.0.0"
61
+ },
62
+ "dependencies": {
63
+ "dotenv": "^17.2.3"
64
  }
65
  }
trigo-web/public/onnx/{GPT2CausalLM_ep0015_evaluation.onnx → 20251220-trigo-value-llama-l6-h64-251220-value0.02/LlamaCausalLM_ep0036_evaluation.onnx} RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:91e47b22b07bc5bef2083637dabd6d51e3c9f2faf84fdaa808f5ab3afaf769f8
3
- size 3676431
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f28ac6d8655eec8189887baff3677159e0c9b4dbc4c415884de9159b6d0ee621
3
+ size 1387656
trigo-web/public/onnx/20251220-trigo-value-llama-l6-h64-251220-value0.02/LlamaCausalLM_ep0036_tree.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:84f0441fa4688cd3b3b02195237a3bce8ff9380f1ba57db6fd8fe1fa9ffcec01
3
+ size 1378983