Justxd22 commited on
Commit
82f32a6
Β·
1 Parent(s): e7dcd7e

feat: Implement Murder.Ai Detective Interface with noir theme

Browse files

- Added noir.css for styling the game interface with a dark theme.
- Developed game_logic.js to manage game state, communication with the server, and UI rendering.
- Created game_interface.html as the main template for the detective interface, including loading screen, suspect files, evidence board, tools, and chat log.
- Integrated dynamic elements for suspects, evidence, and chat messages.
- Established API communication for game actions and updates.

.gitignore CHANGED
@@ -2,4 +2,5 @@
2
  .env
3
  __pycache__/
4
  .vscode/
5
- game/__pycache__/
 
 
2
  .env
3
  __pycache__/
4
  .vscode/
5
+ game/__pycache__/
6
+ ref/
app.py CHANGED
@@ -1,278 +1,211 @@
1
  import gradio as gr
2
  import os
 
 
 
 
3
  from game import game_engine
4
- from ui.components import format_tool_result_markdown, format_suspect_card
5
-
6
- # Load CSS
7
- with open("ui/styles.css", "r") as f:
8
- custom_css = f.read()
9
-
10
- def get_current_game(session_id):
11
- if not session_id:
12
- return None
13
- return game_engine.get_game(session_id)
14
-
15
- def start_new_game_ui(scenario_name):
16
- """Starts a new game based on scenario selection."""
17
-
18
- difficulty = "medium" # Default
19
- if "Coffee Shop" in scenario_name:
20
- difficulty = "easy"
21
- elif "Gallery" in scenario_name:
22
- difficulty = "hard"
23
-
24
- session_id, game = game_engine.start_game(difficulty)
25
-
26
- # Generate Suspect Cards HTML and Button Updates
27
- suspects = game.scenario["suspects"]
28
- cards = [format_suspect_card(s) for s in suspects]
29
-
30
- # Pad if fewer than 4
31
- while len(cards) < 4:
32
- cards.append("")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
- # Enable buttons for existing suspects
35
- btn_updates = [gr.update(interactive=True, visible=True) for _ in suspects]
36
- while len(btn_updates) < 4:
37
- btn_updates.append(gr.update(interactive=False, visible=False))
 
38
 
39
- # Initial Evidence Board
40
- victim_info = game.scenario['victim']
41
- # Basic extraction of details if available, otherwise generic placeholders (based on plan/typical structure)
42
- found_at = victim_info.get('found_at', victim_info.get('location', 'Unknown Location'))
43
- cause_of_death = victim_info.get('cause_of_death', 'Unknown')
44
-
45
- evidence_md = f"""## πŸ“ Case: {game.scenario['title']}
46
- **Victim:** {victim_info['name']}
47
- **Time of Death:** {victim_info['time_of_death']}
48
- **Found At:** {found_at}
49
- **Cause of Death:** {cause_of_death}
50
 
51
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- ### πŸ”Ž Evidence Log
54
- *Evidence revealed during investigation will appear here.*
55
- """
56
-
57
- # Initial Chat
58
- initial_chat = [
59
- {"role": "assistant", "content": f"CASE FILE LOADED: {game.scenario['title']}\nVictim: {game.scenario['victim']['name']}\nTime of Death: {game.scenario['victim']['time_of_death']}"}
60
- ]
61
-
62
- return (
63
- session_id,
64
- cards[0], cards[1], cards[2], cards[3], # Suspect HTML
65
- btn_updates[0], btn_updates[1], btn_updates[2], btn_updates[3], # Suspect Buttons
66
- initial_chat, # Chatbot
67
- evidence_md, # Evidence board
68
- f"Round: {game.round}/5 | Points: {game.points}", # Stats
69
- None, # Reset selected suspect ID
70
- gr.update(interactive=True), # Question input
71
- gr.update(interactive=True), # Question btn
72
- gr.update(interactive=True), # Tool btn
73
- "**Select a suspect to begin interrogation.**" # Status
74
- )
75
 
76
- def select_suspect_by_index(session_id, index):
77
- game = get_current_game(session_id)
78
- if not game or index >= len(game.scenario["suspects"]):
79
- return None, "Error"
80
-
81
- suspect = game.scenario["suspects"][index]
82
- return suspect['id'], f"**Interrogating:** {suspect['name']}"
83
 
84
- def submit_question(session_id, suspect_id, question, history):
85
- game = get_current_game(session_id)
86
- if not game:
87
- return history, "Error: No active game."
88
-
89
- if not suspect_id:
90
- return history, "Error: Select a suspect first."
91
-
92
- if not question:
93
- return history, "Error: Enter a question."
94
 
95
- response = game.question_suspect(suspect_id, question)
96
-
97
- # Look up suspect name for nicer chat
98
- suspect_name = next((s["name"] for s in game.scenario["suspects"] if s["id"] == suspect_id), suspect_id)
 
99
 
100
- history.append({"role": "user", "content": f"**Detective to {suspect_name}:** {question}"})
101
- history.append({"role": "assistant", "content": f"**{suspect_name}:** {response}"})
102
-
103
- return history, "" # Clear input
 
 
 
 
 
 
104
 
105
- def use_tool_ui(session_id, tool_name, arg1, history, current_evidence_md):
106
- game = get_current_game(session_id)
107
- if not game:
108
- return history, current_evidence_md, "Error"
109
-
110
- # Construct kwargs based on tool
111
- kwargs = {}
112
- if tool_name == "get_location":
113
- kwargs = {"phone_number": arg1}
114
- elif tool_name == "get_footage":
115
- kwargs = {"location": arg1}
116
- elif tool_name == "get_dna_test":
117
- kwargs = {"evidence_id": arg1}
118
- elif tool_name == "call_alibi":
119
- kwargs = {"phone_number": arg1}
120
-
121
- result = game.use_tool(tool_name, **kwargs)
122
-
123
- # Format the result
124
- formatted_result = format_tool_result_markdown(tool_name, result)
125
 
126
- # Update History
127
- history.append({"role": "assistant", "content": f"πŸ”§ **System:** Used {tool_name}\nInput: {arg1}\n\n{formatted_result}"})
128
 
129
- # Update Evidence Board
130
- # We append the new formatted result to the markdown
131
- new_evidence_md = current_evidence_md + f"\n\n---\n\n{formatted_result}"
132
-
133
- stats = f"Round: {game.round}/5 | Points: {game.points}"
134
-
135
- return history, new_evidence_md, stats
136
-
137
- def next_round_ui(session_id):
138
- game = get_current_game(session_id)
139
- if not game:
140
- return "No game"
141
-
142
- not_over = game.advance_round()
143
- stats = f"Round: {game.round}/5 | Points: {game.points}"
144
-
145
- if not not_over:
146
- stats += " [GAME OVER]"
147
-
148
- return stats
149
-
150
-
151
- # --- UI LAYOUT ---
152
 
153
- with gr.Blocks(title="Murder.Ai") as demo:
154
-
155
- gr.Markdown("# πŸ•΅οΈ MURDER.AI")
156
-
157
- session_state = gr.State("")
158
-
159
- # Top Controls
160
- with gr.Row():
161
- scenario_selector = gr.Dropdown(
162
- choices=["The Silicon Valley Incident (Medium)", "The Coffee Shop Murder (Easy)", "The Gallery Heist (Hard)"],
163
- value="The Silicon Valley Incident (Medium)",
164
- label="Select Case Scenario"
165
- )
166
- start_btn = gr.Button("πŸ“‚ LOAD CASE FILE", variant="primary")
167
-
168
- with gr.Row():
169
- # Left: Suspect Cards
170
- with gr.Column(scale=1):
171
- gr.Markdown("## Suspects")
172
-
173
- # Suspect 1
174
- with gr.Group():
175
- s1_html = gr.HTML()
176
- s1_btn = gr.Button("Interrogate", variant="secondary", visible=False)
177
-
178
- # Suspect 2
179
- with gr.Group():
180
- s2_html = gr.HTML()
181
- s2_btn = gr.Button("Interrogate", variant="secondary", visible=False)
182
-
183
- # Suspect 3
184
- with gr.Group():
185
- s3_html = gr.HTML()
186
- s3_btn = gr.Button("Interrogate", variant="secondary", visible=False)
187
-
188
- # Suspect 4
189
- with gr.Group():
190
- s4_html = gr.HTML()
191
- s4_btn = gr.Button("Interrogate", variant="secondary", visible=False)
192
-
193
- # Center: Main Game Area
194
- with gr.Column(scale=2):
195
- game_stats = gr.Markdown("Round: 0/5 | Points: 10")
196
- interrogation_status = gr.Markdown("**Select a suspect to begin interrogation.**")
197
-
198
- chatbot = gr.Chatbot(
199
- label="Investigation Log",
200
- height=500,
201
- )
202
-
203
- # State variable to store selected suspect ID
204
- selected_suspect_id = gr.State(value=None)
205
-
206
- with gr.Row():
207
- question_input = gr.Textbox(label="Question", placeholder="Where were you?", interactive=False, scale=4)
208
- ask_btn = gr.Button("Ask", variant="secondary", interactive=False, scale=1)
209
-
210
- gr.Markdown("### Tools")
211
- with gr.Row():
212
- tool_dropdown = gr.Dropdown(
213
- label="Select Tool",
214
- choices=["get_location", "get_footage", "get_dna_test", "call_alibi"],
215
- value="get_location"
216
- )
217
- arg1_input = gr.Textbox(label="Input (Phone / Location / ID)")
218
- use_tool_btn = gr.Button("Use Tool", interactive=False)
219
 
220
- next_round_btn = gr.Button("▢️ Next Round / End Game")
221
-
222
- # Right: Evidence Board
223
- with gr.Column(scale=1):
224
- gr.Markdown("## Evidence Board")
225
- evidence_board = gr.Markdown(
226
- value="Select a case to begin..."
 
 
 
 
227
  )
 
228
 
229
- # --- EVENTS ---
230
-
231
- start_btn.click(
232
- fn=start_new_game_ui,
233
- inputs=[scenario_selector],
234
- outputs=[
235
- session_state,
236
- s1_html, s2_html, s3_html, s4_html,
237
- s1_btn, s2_btn, s3_btn, s4_btn,
238
- chatbot,
239
- evidence_board,
240
- game_stats,
241
- selected_suspect_id,
242
- question_input,
243
- ask_btn,
244
- use_tool_btn,
245
- interrogation_status
246
- ]
247
- )
248
 
249
- # Suspect Selection Events
250
- s1_btn.click(fn=lambda s: select_suspect_by_index(s, 0), inputs=[session_state], outputs=[selected_suspect_id, interrogation_status])
251
- s2_btn.click(fn=lambda s: select_suspect_by_index(s, 1), inputs=[session_state], outputs=[selected_suspect_id, interrogation_status])
252
- s3_btn.click(fn=lambda s: select_suspect_by_index(s, 2), inputs=[session_state], outputs=[selected_suspect_id, interrogation_status])
253
- s4_btn.click(fn=lambda s: select_suspect_by_index(s, 3), inputs=[session_state], outputs=[selected_suspect_id, interrogation_status])
254
 
255
- ask_btn.click(
256
- fn=submit_question,
257
- inputs=[session_state, selected_suspect_id, question_input, chatbot],
258
- outputs=[chatbot, question_input]
259
- )
260
-
261
- use_tool_btn.click(
262
- fn=use_tool_ui,
263
- inputs=[session_state, tool_dropdown, arg1_input, chatbot, evidence_board],
264
- outputs=[chatbot, evidence_board, game_stats]
265
  )
266
 
267
- next_round_btn.click(
268
- fn=next_round_ui,
269
- inputs=[session_state],
270
- outputs=[game_stats]
 
 
 
 
 
 
 
 
 
271
  )
272
 
273
- demo.launch(
274
- server_name="0.0.0.0",
275
- server_port=7860,
276
- allowed_paths=["."],
277
- css=custom_css
278
- )
 
1
  import gradio as gr
2
  import os
3
+ import json
4
+ import uvicorn
5
+ from fastapi import FastAPI
6
+ from fastapi.staticfiles import StaticFiles
7
  from game import game_engine
8
+ from pydantic import BaseModel
9
+
10
+ # --- Setup FastAPI for Static Files ---
11
+ app = FastAPI()
12
+ # Ensure directories exist
13
+ os.makedirs("ui/static", exist_ok=True)
14
+ app.mount("/static", StaticFiles(directory="ui/static"), name="static")
15
+
16
+ # --- API Bridge ---
17
+
18
+ class BridgeRequest(BaseModel):
19
+ action: str
20
+ data: dict = {}
21
+
22
+ @app.post("/api/bridge")
23
+ async def api_bridge(request: BridgeRequest):
24
+ """Direct API endpoint for game logic communication."""
25
+ input_data = json.dumps({"action": request.action, "data": request.data})
26
+ print(f"API Bridge Received: {input_data}")
27
+ response = session.handle_input(input_data)
28
+ return response or {}
29
+
30
+ # --- Game Logic Wrapper ---
31
+
32
+ class GameSession:
33
+ def __init__(self):
34
+ self.session_id = None
35
+ self.game = None
36
+
37
+ def start(self, difficulty="medium"):
38
+ self.session_id, self.game = game_engine.start_game(difficulty)
39
+ return self._get_init_data()
40
+
41
+ def _get_init_data(self):
42
+ if not self.game:
43
+ return None
44
+ return {
45
+ "action": "init_game",
46
+ "data": {
47
+ "scenario": self.game.scenario,
48
+ "round": self.game.round,
49
+ "points": self.game.points
50
+ }
51
+ }
52
+
53
+ def handle_input(self, input_json):
54
+ if not input_json:
55
+ return None
56
+
57
+ try:
58
+ data = json.loads(input_json)
59
+ except:
60
+ return None
61
+
62
+ action = data.get("action")
63
+ payload = data.get("data", {})
64
 
65
+ if action == "ready":
66
+ # Wait for explicit start from Gradio UI, or return existing state
67
+ if self.game:
68
+ return self._get_init_data()
69
+ return None # Wait for user to pick case
70
 
71
+ if not self.game:
72
+ return None
 
 
 
 
 
 
 
 
 
73
 
74
+ if action == "select_suspect":
75
+ return None
76
+
77
+ if action == "chat_message":
78
+ suspect_id = payload.get("suspect_id")
79
+ message = payload.get("message")
80
+ response = self.game.question_suspect(suspect_id, message)
81
+
82
+ suspect_name = next((s["name"] for s in self.game.scenario["suspects"] if s["id"] == suspect_id), "Suspect")
83
+
84
+ return {
85
+ "action": "update_chat",
86
+ "data": {
87
+ "role": "suspect",
88
+ "name": suspect_name,
89
+ "content": response
90
+ }
91
+ }
92
+
93
+ if action == "use_tool":
94
+ tool_name = payload.get("tool")
95
+ arg = payload.get("input")
96
+
97
+ kwargs = {}
98
+ if tool_name == "get_location":
99
+ kwargs = {"phone_number": arg}
100
+ elif tool_name == "get_footage":
101
+ kwargs = {"location": arg}
102
+ elif tool_name == "call_alibi":
103
+ kwargs = {"phone_number": arg}
104
+ elif tool_name == "get_dna_test":
105
+ kwargs = {"evidence_id": arg}
106
+
107
+ result = self.game.use_tool(tool_name, **kwargs)
108
+
109
+ evidence_data = {
110
+ "title": f"Tool: {tool_name}",
111
+ "description": str(result)
112
+ }
113
+
114
+ return {
115
+ "action": "add_evidence",
116
+ "data": evidence_data
117
+ }
118
 
119
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
+ session = GameSession()
 
 
 
 
 
 
122
 
123
+ # --- Gradio App ---
 
 
 
 
 
 
 
 
 
124
 
125
+ def get_game_iframe():
126
+ with open("ui/templates/game_interface.html", "r") as f:
127
+ html_content = f.read()
128
+ html_content = html_content.replace('../static/', '/static/')
129
+ html_content_escaped = html_content.replace('"', '&quot;')
130
 
131
+ # Iframe is hidden initially
132
+ iframe = f"""
133
+ <iframe
134
+ id="game-iframe"
135
+ srcdoc="{html_content_escaped}"
136
+ style="width: 100%; height: 95vh; border: none;"
137
+ allow="autoplay; fullscreen"
138
+ ></iframe>
139
+ """
140
+ return iframe
141
 
142
+ def start_game_from_ui(case_name):
143
+ difficulty = "medium"
144
+ if "Coffee" in case_name: difficulty = "easy"
145
+ if "Gallery" in case_name: difficulty = "hard"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
+ init_data = session.start(difficulty)
 
148
 
149
+ # Return visible updates
150
+ return (
151
+ gr.update(visible=False), # Hide selector row
152
+ gr.update(visible=True), # Show game frame
153
+ json.dumps(init_data) # Send init data to bridge
154
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
+ css = """
157
+ #bridge-input, #bridge-output { display: none !important; }
158
+ .gradio-container { padding: 0 !important; max-width: 100% !important; height: 100vh !important; display: flex; flex-direction: column; }
159
+ #game-frame-container { flex-grow: 1; height: 100% !important; border: none; overflow: hidden; padding: 0; }
160
+ #game-frame-container > .html-container { height: 100% !important; display: flex; flex-direction: column; }
161
+ #game-frame-container .prose { flex-grow: 1; height: 100% !important; max-width: 100% !important; }
162
+ footer { display: none !important; }
163
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
 
165
+ with gr.Blocks(title="Murder.Ai", fill_height=True) as demo:
166
+ gr.HTML(f"<style>{css}</style>")
167
+
168
+ # Case Selector (Visible Initially)
169
+ with gr.Row(elem_id="case-selector-row", visible=True) as selector_row:
170
+ with gr.Column():
171
+ gr.Markdown("# πŸ•΅οΈ MURDER.AI - CASE FILES")
172
+ case_dropdown = gr.Dropdown(
173
+ choices=["The Silicon Valley Incident (Medium)", "The Coffee Shop Murder (Easy)", "The Gallery Heist (Hard)"],
174
+ value="The Silicon Valley Incident (Medium)",
175
+ label="Select Case to Investigate"
176
  )
177
+ start_btn = gr.Button("πŸ“‚ OPEN CASE FILE", variant="primary")
178
 
179
+ # Game Frame (Hidden Initially)
180
+ with gr.Group(visible=False, elem_id="game-frame-container") as game_group:
181
+ game_html = gr.HTML(value=get_game_iframe())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
+ bridge_input = gr.Textbox(elem_id="bridge-input", visible=True)
184
+ bridge_output = gr.Textbox(elem_id="bridge-output", visible=True)
 
 
 
185
 
186
+ # Start Game Event
187
+ start_btn.click(
188
+ fn=start_game_from_ui,
189
+ inputs=[case_dropdown],
190
+ outputs=[selector_row, game_group, bridge_output]
 
 
 
 
 
191
  )
192
 
193
+ # Bridge Logic (Python -> JS)
194
+ bridge_output.change(
195
+ None,
196
+ inputs=[bridge_output],
197
+ js="""
198
+ (data) => {
199
+ if (!data) return;
200
+ const iframe = document.querySelector('#game-frame-container iframe');
201
+ if (iframe && iframe.contentWindow) {
202
+ iframe.contentWindow.postMessage(JSON.parse(data), '*');
203
+ }
204
+ }
205
+ """
206
  )
207
 
208
+ app = gr.mount_gradio_app(app, demo, path="/")
209
+
210
+ if __name__ == "__main__":
211
+ uvicorn.run(app, host="0.0.0.0", port=7860)
 
 
more_docs.md ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt CHANGED
@@ -1,3 +1,5 @@
1
  gradio>=5.0
2
  google-generativeai
3
  python-dotenv
 
 
 
1
  gradio>=5.0
2
  google-generativeai
3
  python-dotenv
4
+ fastapi
5
+ uvicorn[standard]
ui/static/css/noir.css ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* noir.css - Murder.Ai Theme */
2
+
3
+ @import url('https://fonts.googleapis.com/css2?family=Special+Elite&family=Roboto:wght@300;700&display=swap');
4
+
5
+ :root {
6
+ --bg-color: #1a1a1a;
7
+ --desk-color: #2c1e12;
8
+ --paper-color: #f4e4bc;
9
+ --ink-color: #2a2a2a;
10
+ --accent-red: #8a0303;
11
+ --string-color: #a31515;
12
+ --highlight-glow: rgba(255, 255, 255, 0.1);
13
+ }
14
+
15
+ body, html {
16
+ margin: 0;
17
+ padding: 0;
18
+ width: 100%;
19
+ height: 100%;
20
+ background-color: var(--bg-color);
21
+ font-family: 'Roboto', sans-serif;
22
+ overflow: hidden;
23
+ user-select: none; /* Game-like feel */
24
+ }
25
+
26
+ #game-container {
27
+ display: grid;
28
+ grid-template-columns: 250px 1fr 250px;
29
+ grid-template-rows: 60px 1fr 250px;
30
+ height: 100vh;
31
+ width: 100vw;
32
+ background:
33
+ radial-gradient(circle at 50% 50%, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.8) 100%),
34
+ repeating-linear-gradient(45deg, #2c1e12 0px, #251810 10px, #2c1e12 20px); /* Faux Wood */
35
+ box-shadow: inset 0 0 100px black;
36
+ }
37
+
38
+ /* --- Header --- */
39
+ #top-bar {
40
+ grid-column: 1 / -1;
41
+ background: rgba(0, 0, 0, 0.8);
42
+ color: #eee;
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: space-between;
46
+ padding: 0 20px;
47
+ border-bottom: 2px solid #444;
48
+ font-family: 'Special Elite', monospace;
49
+ z-index: 10;
50
+ }
51
+
52
+ .game-title {
53
+ font-size: 1.5rem;
54
+ letter-spacing: 2px;
55
+ color: var(--accent-red);
56
+ text-shadow: 0 0 5px var(--accent-red);
57
+ }
58
+
59
+ .stats-panel {
60
+ display: flex;
61
+ gap: 20px;
62
+ font-size: 1.1rem;
63
+ }
64
+
65
+ /* --- Left Panel: Suspect Files --- */
66
+ #suspect-files {
67
+ grid-row: 2 / 3;
68
+ background: rgba(0, 0, 0, 0.3);
69
+ padding: 20px;
70
+ display: flex;
71
+ flex-direction: column;
72
+ gap: 15px;
73
+ overflow-y: auto;
74
+ border-right: 1px solid rgba(255,255,255,0.1);
75
+ }
76
+
77
+ .folder-label {
78
+ font-family: 'Special Elite', cursive;
79
+ color: #aaa;
80
+ text-align: center;
81
+ border-bottom: 1px dashed #555;
82
+ padding-bottom: 5px;
83
+ margin-bottom: 10px;
84
+ }
85
+
86
+ .suspect-card {
87
+ background-color: var(--paper-color);
88
+ padding: 10px 10px 25px 10px; /* Polaroid bottom heavy */
89
+ box-shadow: 3px 3px 5px rgba(0,0,0,0.5);
90
+ transform: rotate(-2deg);
91
+ transition: transform 0.2s, box-shadow 0.2s;
92
+ cursor: pointer;
93
+ position: relative;
94
+ }
95
+
96
+ .suspect-card:hover {
97
+ transform: rotate(0deg) scale(1.05);
98
+ box-shadow: 5px 5px 15px rgba(0,0,0,0.7);
99
+ z-index: 5;
100
+ }
101
+
102
+ .suspect-card:nth-child(even) {
103
+ transform: rotate(1deg);
104
+ }
105
+
106
+ .suspect-img {
107
+ width: 100%;
108
+ height: 120px;
109
+ background-color: #333;
110
+ margin-bottom: 10px;
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: center;
114
+ color: #777;
115
+ font-size: 2rem;
116
+ filter: sepia(0.8) contrast(1.2);
117
+ }
118
+
119
+ .suspect-name {
120
+ font-family: 'Special Elite', cursive;
121
+ font-size: 1.1rem;
122
+ color: var(--ink-color);
123
+ text-align: center;
124
+ }
125
+
126
+ .suspect-phone {
127
+ font-size: 0.8rem;
128
+ text-align: center;
129
+ color: #555;
130
+ margin-top: 5px;
131
+ cursor: pointer;
132
+ border: 1px dashed #aaa;
133
+ padding: 2px;
134
+ border-radius: 3px;
135
+ }
136
+
137
+ .suspect-phone:hover {
138
+ background: rgba(0,0,0,0.05);
139
+ color: #000;
140
+ }
141
+
142
+ .status-stamp {
143
+ position: absolute;
144
+ top: 50%;
145
+ left: 50%;
146
+ transform: translate(-50%, -50%) rotate(-15deg);
147
+ border: 3px solid var(--accent-red);
148
+ color: var(--accent-red);
149
+ font-weight: bold;
150
+ padding: 5px 10px;
151
+ font-family: 'Special Elite', cursive;
152
+ font-size: 1.2rem;
153
+ opacity: 0;
154
+ transition: opacity 0.3s;
155
+ pointer-events: none;
156
+ }
157
+
158
+ .suspect-card.guilty .status-stamp {
159
+ opacity: 1;
160
+ content: "GUILTY";
161
+ }
162
+
163
+ /* --- Center Panel: The Evidence Board --- */
164
+ #evidence-board {
165
+ grid-row: 2 / 3;
166
+ grid-column: 2 / 3;
167
+ position: relative;
168
+ /* background-image: url('data:image/svg+xml;base64,PHN2Zy4uLz4='); NO SVG HERE, causes 404 if broken */
169
+ background-color: #c08848;
170
+ box-shadow: inset 0 0 50px rgba(0,0,0,0.6);
171
+ overflow: hidden; /* Or auto if we want scrolling */
172
+ cursor: crosshair;
173
+ }
174
+
175
+ .evidence-item {
176
+ position: absolute;
177
+ background: var(--paper-color);
178
+ padding: 10px;
179
+ box-shadow: 2px 2px 4px rgba(0,0,0,0.4);
180
+ max-width: 200px;
181
+ font-family: 'Special Elite', cursive;
182
+ font-size: 0.9rem;
183
+ color: var(--ink-color);
184
+ border-top: 1px solid rgba(255,255,255,0.5);
185
+ transition: transform 0.2s;
186
+ }
187
+
188
+ .evidence-item.case-file {
189
+ background: #e8dcc8;
190
+ width: 250px;
191
+ max-width: 300px;
192
+ border: 1px solid #888;
193
+ padding: 15px;
194
+ z-index: 5;
195
+ }
196
+
197
+ .evidence-item:before {
198
+ /* Pushpin */
199
+ content: '';
200
+ position: absolute;
201
+ top: -5px;
202
+ left: 50%;
203
+ width: 10px;
204
+ height: 10px;
205
+ background: red;
206
+ border-radius: 50%;
207
+ box-shadow: 1px 1px 2px black;
208
+ }
209
+
210
+ .evidence-item:hover {
211
+ transform: scale(1.1);
212
+ z-index: 10;
213
+ }
214
+
215
+ /* Red String Implementation (SVG Overlay) */
216
+ #string-layer {
217
+ position: absolute;
218
+ top: 0;
219
+ left: 0;
220
+ width: 100%;
221
+ height: 100%;
222
+ pointer-events: none;
223
+ }
224
+
225
+ /* --- Right Panel: Tools --- */
226
+ #tools-panel {
227
+ grid-row: 2 / 3;
228
+ grid-column: 3 / 4;
229
+ background: rgba(0, 0, 0, 0.3);
230
+ padding: 20px;
231
+ display: flex;
232
+ flex-direction: column;
233
+ gap: 20px;
234
+ border-left: 1px solid rgba(255,255,255,0.1);
235
+ }
236
+
237
+ .tool-btn {
238
+ background: #333;
239
+ border: 2px solid #555;
240
+ color: #ccc;
241
+ padding: 15px;
242
+ text-align: center;
243
+ cursor: pointer;
244
+ transition: all 0.2s;
245
+ border-radius: 5px;
246
+ display: flex;
247
+ flex-direction: column;
248
+ align-items: center;
249
+ gap: 5px;
250
+ }
251
+
252
+ .tool-btn:hover {
253
+ background: #444;
254
+ border-color: #777;
255
+ color: white;
256
+ transform: translateY(-2px);
257
+ }
258
+
259
+ .tool-btn:active {
260
+ transform: translateY(1px);
261
+ }
262
+
263
+ .tool-icon {
264
+ font-size: 1.5rem;
265
+ }
266
+
267
+ /* --- Bottom Panel: Chat / Log --- */
268
+ #chat-panel {
269
+ grid-column: 1 / -1;
270
+ grid-row: 3 / 4;
271
+ background: #111;
272
+ border-top: 4px solid #333;
273
+ display: grid;
274
+ grid-template-columns: 1fr 300px;
275
+ font-family: 'Special Elite', monospace;
276
+ }
277
+
278
+ #chat-log {
279
+ padding: 20px;
280
+ overflow-y: auto;
281
+ color: #ddd;
282
+ background:
283
+ linear-gradient(rgba(18, 16, 16, 0.95), rgba(18, 16, 16, 0.95));
284
+ /* Removed faulty SVG URL here too */
285
+ }
286
+
287
+ .chat-message {
288
+ margin-bottom: 15px;
289
+ padding: 10px;
290
+ border-left: 3px solid #555;
291
+ animation: typewriter 0.5s ease-out;
292
+ }
293
+
294
+ .chat-message.detective {
295
+ border-left-color: #4a9eff;
296
+ background: rgba(74, 158, 255, 0.05);
297
+ }
298
+
299
+ .chat-message.suspect {
300
+ border-left-color: #ff4a4a;
301
+ background: rgba(255, 74, 74, 0.05);
302
+ }
303
+
304
+ .chat-message.system {
305
+ border-left-color: #4aff4a;
306
+ font-style: italic;
307
+ color: #888;
308
+ }
309
+
310
+ #chat-input-area {
311
+ background: #222;
312
+ padding: 15px;
313
+ display: flex;
314
+ flex-direction: column;
315
+ gap: 10px;
316
+ border-left: 1px solid #333;
317
+ }
318
+
319
+ textarea {
320
+ width: 100%;
321
+ height: 100%;
322
+ background: #000;
323
+ border: 1px solid #444;
324
+ color: #0f0; /* Matrix style input */
325
+ padding: 10px;
326
+ font-family: 'Courier New', monospace;
327
+ resize: none;
328
+ }
329
+
330
+ textarea:focus {
331
+ outline: none;
332
+ border-color: #666;
333
+ }
334
+
335
+ button.send-btn {
336
+ background: var(--accent-red);
337
+ color: white;
338
+ border: none;
339
+ padding: 10px;
340
+ font-family: 'Special Elite', cursive;
341
+ cursor: pointer;
342
+ font-size: 1.1rem;
343
+ }
344
+
345
+ button.send-btn:hover {
346
+ background: #b30000;
347
+ }
348
+
349
+ /* --- Animations --- */
350
+ @keyframes typewriter {
351
+ from { opacity: 0; transform: translateY(10px); }
352
+ to { opacity: 1; transform: translateY(0); }
353
+ }
354
+
355
+ /* --- Loading Overlay --- */
356
+ #loading-overlay {
357
+ position: fixed;
358
+ top: 0;
359
+ left: 0;
360
+ width: 100%;
361
+ height: 100%;
362
+ background: black;
363
+ z-index: 100;
364
+ display: flex;
365
+ flex-direction: column;
366
+ align-items: center;
367
+ justify-content: center;
368
+ color: white;
369
+ font-family: 'Special Elite', cursive;
370
+ transition: opacity 1s;
371
+ pointer-events: none;
372
+ }
373
+
374
+ .loader-spinner {
375
+ width: 50px;
376
+ height: 50px;
377
+ border: 5px solid #333;
378
+ border-top-color: var(--accent-red);
379
+ border-radius: 50%;
380
+ animation: spin 1s linear infinite;
381
+ margin-bottom: 20px;
382
+ }
383
+
384
+ @keyframes spin {
385
+ to { transform: rotate(360deg); }
386
+ }
ui/static/js/game_logic.js ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* game_logic.js - Murder.Ai Frontend Logic */
2
+
3
+ console.log("πŸ•΅οΈ Murder.Ai Detective Interface Initialized");
4
+
5
+ // State
6
+ let gameState = {
7
+ suspects: [],
8
+ evidence: [],
9
+ chatLog: [],
10
+ currentSuspect: null
11
+ };
12
+
13
+ // --- Bridge: Communication with Parent (Python/Gradio) ---
14
+
15
+ // We now use direct API calls for reliability
16
+ async function sendAction(action, data) {
17
+ // console.log("πŸ“€ Sending API request:", action, data);
18
+ try {
19
+ const response = await fetch('/api/bridge', {
20
+ method: 'POST',
21
+ headers: {
22
+ 'Content-Type': 'application/json',
23
+ },
24
+ body: JSON.stringify({ action, data }),
25
+ });
26
+
27
+ if (!response.ok) throw new Error(`API Error: ${response.status}`);
28
+
29
+ const result = await response.json();
30
+ // console.log("πŸ“₯ API Response:", result);
31
+
32
+ if (result && result.action) {
33
+ handleServerMessage(result);
34
+ }
35
+ } catch (e) {
36
+ console.error("Bridge Error:", e);
37
+ }
38
+ }
39
+
40
+ // Keep listener for any future server-pushed events (if we add sockets later)
41
+ window.addEventListener('message', function(event) {
42
+ const { action, data } = event.data;
43
+ if (action) handleServerMessage({ action, data });
44
+ });
45
+
46
+ function handleServerMessage(message) {
47
+ const { action, data } = message;
48
+ switch(action) {
49
+ case 'init_game':
50
+ initializeGame(data);
51
+ break;
52
+ case 'update_chat':
53
+ addChatMessage(data.role, data.content, data.name);
54
+ break;
55
+ case 'add_evidence':
56
+ addEvidenceToBoard(data);
57
+ break;
58
+ case 'update_status':
59
+ updateStatus(data);
60
+ break;
61
+ case 'game_over':
62
+ triggerGameOver(data);
63
+ break;
64
+ default:
65
+ console.warn("Unknown action:", action);
66
+ }
67
+ }
68
+
69
+ // --- Game Logic ---
70
+
71
+ function initializeGame(data) {
72
+ gameState = data;
73
+ renderSuspects();
74
+ document.getElementById('loading-overlay').style.opacity = 0;
75
+ setTimeout(() => {
76
+ document.getElementById('loading-overlay').style.display = 'none';
77
+ }, 1000);
78
+
79
+ addChatMessage('system', `CASE LOADED: ${data.scenario.title}`);
80
+ addChatMessage('system', `VICTIM: ${data.scenario.victim.name}`);
81
+
82
+ // Update UI stats
83
+ document.getElementById('round-display').innerText = `${data.round}/5`;
84
+ document.getElementById('points-display').innerText = data.points;
85
+
86
+ renderCaseFile(data.scenario);
87
+ }
88
+
89
+ function renderCaseFile(scenario) {
90
+ const victim = scenario.victim;
91
+ const details = `
92
+ <div style="border-bottom: 2px solid black; margin-bottom: 5px; padding-bottom: 5px;"><strong>POLICE REPORT</strong></div>
93
+ <div><strong>CASE:</strong> ${scenario.title}</div>
94
+ <div><strong>VICTIM:</strong> ${victim.name} (${victim.age})</div>
95
+ <div><strong>OCCUPATION:</strong> ${victim.occupation}</div>
96
+ <div><strong>TIME OF DEATH:</strong> ${victim.time_of_death}</div>
97
+ <div><strong>LOCATION:</strong> ${victim.location || "Unknown"}</div>
98
+ `;
99
+
100
+ addEvidenceToBoard({
101
+ title: "CASE FILE #A47",
102
+ description: details,
103
+ type: "file"
104
+ }, 20, 20);
105
+ }
106
+
107
+ // --- UI Rendering: Suspects ---
108
+
109
+ function renderSuspects() {
110
+ const container = document.getElementById('suspect-files');
111
+ container.innerHTML = '<div class="folder-label">SUSPECTS</div>';
112
+
113
+ if (!gameState.scenario || !gameState.scenario.suspects) return;
114
+
115
+ gameState.scenario.suspects.forEach(suspect => {
116
+ const card = document.createElement('div');
117
+ card.className = 'suspect-card';
118
+ card.dataset.id = suspect.id;
119
+ card.onclick = () => selectSuspect(suspect.id);
120
+
121
+ // Placeholder Avatar (Initials)
122
+ const initials = suspect.name.split(' ').map(n => n[0]).join('');
123
+
124
+ card.innerHTML = `
125
+ <div class="suspect-img">${initials}</div>
126
+ <div class="suspect-name">${suspect.name}</div>
127
+ <div class="suspect-role">${suspect.role}</div>
128
+ <div class="suspect-phone" title="Click to copy" onclick="event.stopPropagation(); copyToClipboard('${suspect.phone_number}')">
129
+ πŸ“ž ${suspect.phone_number} πŸ“‹
130
+ </div>
131
+ <div class="status-stamp"></div>
132
+ `;
133
+
134
+ container.appendChild(card);
135
+ });
136
+ }
137
+
138
+ function copyToClipboard(text) {
139
+ navigator.clipboard.writeText(text).then(() => {
140
+ alert("Copied: " + text);
141
+ }).catch(err => {
142
+ console.error('Failed to copy: ', err);
143
+ });
144
+ }
145
+
146
+ function selectSuspect(suspectId) {
147
+ gameState.currentSuspect = suspectId;
148
+ const suspect = gameState.scenario.suspects.find(s => s.id === suspectId);
149
+
150
+ // Highlight visual
151
+ document.querySelectorAll('.suspect-card').forEach(el => {
152
+ el.style.border = el.dataset.id === suspectId ? '2px solid red' : 'none';
153
+ });
154
+
155
+ addChatMessage('system', `Selected suspect: ${suspect.name}. You may now question them.`);
156
+ sendAction('select_suspect', { suspect_id: suspectId });
157
+ }
158
+
159
+ // --- UI Rendering: Chat ---
160
+
161
+ function addChatMessage(role, text, name="System") {
162
+ const log = document.getElementById('chat-log');
163
+ const msg = document.createElement('div');
164
+
165
+ let className = 'system';
166
+ if (role === 'user' || role === 'detective') className = 'detective';
167
+ if (role === 'assistant' || role === 'suspect') className = 'suspect';
168
+
169
+ msg.className = `chat-message ${className}`;
170
+
171
+ // Format: NAME: Message
172
+ const displayName = name ? name.toUpperCase() : role.toUpperCase();
173
+ msg.innerHTML = `<strong>${displayName}:</strong> ${text}`;
174
+
175
+ log.appendChild(msg);
176
+ log.scrollTop = log.scrollHeight;
177
+ }
178
+
179
+ function sendUserMessage() {
180
+ const input = document.getElementById('chat-input');
181
+ const text = input.value.trim();
182
+
183
+ if (!text) return;
184
+
185
+ if (!gameState.currentSuspect) {
186
+ alert("Select a suspect first!");
187
+ return;
188
+ }
189
+
190
+ addChatMessage('detective', text, "YOU");
191
+ sendAction('chat_message', {
192
+ suspect_id: gameState.currentSuspect,
193
+ message: text
194
+ });
195
+
196
+ input.value = '';
197
+ }
198
+
199
+ // --- UI Rendering: Evidence Board ---
200
+
201
+ function addEvidenceToBoard(evidenceData, fixedX = null, fixedY = null) {
202
+ const board = document.getElementById('evidence-board');
203
+
204
+ const item = document.createElement('div');
205
+ item.className = 'evidence-item';
206
+ if (evidenceData.type === 'file') {
207
+ item.classList.add('case-file');
208
+ item.innerHTML = evidenceData.description;
209
+ } else {
210
+ item.innerHTML = `
211
+ <strong>${evidenceData.title || "Evidence"}</strong><br>
212
+ ${evidenceData.description || "No details."}
213
+ `;
214
+ }
215
+
216
+ // Placement
217
+ let x, y;
218
+ if (fixedX !== null && fixedY !== null) {
219
+ x = fixedX;
220
+ y = fixedY;
221
+ item.style.transform = 'rotate(-2deg)'; // Slight tilt for files
222
+ } else {
223
+ x = Math.floor(Math.random() * (board.clientWidth - 200));
224
+ y = Math.floor(Math.random() * (board.clientHeight - 100));
225
+ item.style.transform = `rotate(${Math.random() * 10 - 5}deg)`;
226
+ }
227
+
228
+ item.style.left = x + 'px';
229
+ item.style.top = y + 'px';
230
+
231
+ // Make draggable (simple implementation)
232
+ makeDraggable(item);
233
+
234
+ board.appendChild(item);
235
+
236
+ // Play sound effect (optional)
237
+ }
238
+
239
+ function makeDraggable(elmnt) {
240
+ let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
241
+ elmnt.onmousedown = dragMouseDown;
242
+
243
+ function dragMouseDown(e) {
244
+ e = e || window.event;
245
+ e.preventDefault();
246
+ pos3 = e.clientX;
247
+ pos4 = e.clientY;
248
+ document.onmouseup = closeDragElement;
249
+ document.onmousemove = elementDrag;
250
+ // Bring to front
251
+ elmnt.style.zIndex = 100;
252
+ }
253
+
254
+ function elementDrag(e) {
255
+ e = e || window.event;
256
+ e.preventDefault();
257
+ pos1 = pos3 - e.clientX;
258
+ pos2 = pos4 - e.clientY;
259
+ pos3 = e.clientX;
260
+ pos4 = e.clientY;
261
+ elmnt.style.top = (elmnt.offsetTop - pos2) + "px";
262
+ elmnt.style.left = (elmnt.offsetLeft - pos1) + "px";
263
+ }
264
+
265
+ function closeDragElement() {
266
+ document.onmouseup = null;
267
+ document.onmousemove = null;
268
+ elmnt.style.zIndex = 10;
269
+ }
270
+ }
271
+
272
+ // --- Tools ---
273
+
274
+ function useTool(toolName) {
275
+ const input = prompt(`Enter input for ${toolName} (e.g., phone number):`);
276
+ if (input) {
277
+ sendAction('use_tool', { tool: toolName, input: input });
278
+ }
279
+ }
280
+
281
+ // --- Listeners ---
282
+
283
+ document.getElementById('send-btn').addEventListener('click', sendUserMessage);
284
+ document.getElementById('chat-input').addEventListener('keypress', function (e) {
285
+ if (e.key === 'Enter' && !e.shiftKey) {
286
+ e.preventDefault();
287
+ sendUserMessage();
288
+ }
289
+ });
290
+
291
+ // Tool Buttons
292
+ document.getElementById('tool-map').onclick = () => useTool('get_location');
293
+ document.getElementById('tool-camera').onclick = () => useTool('get_footage');
294
+ document.getElementById('tool-phone').onclick = () => useTool('call_alibi');
295
+ document.getElementById('tool-dna').onclick = () => useTool('get_dna_test');
296
+
297
+ // Notify Parent that we are ready (Retry loop)
298
+ console.log("πŸ“‘ Attempting to connect to game server...");
299
+ const handshakeInterval = setInterval(() => {
300
+ if (gameState.scenario) {
301
+ clearInterval(handshakeInterval);
302
+ console.log("βœ… Connection established.");
303
+ } else {
304
+ console.log("πŸ“‘ Sending 'ready' signal...");
305
+ sendAction('ready', {});
306
+ }
307
+ }, 2000);
308
+
309
+ // Immediate first try
310
+ sendAction('ready', {});
ui/templates/game_interface.html ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Murder.Ai Detective Interface</title>
7
+ <link rel="stylesheet" href="../static/css/noir.css">
8
+ </head>
9
+ <body>
10
+
11
+ <!-- Loading Screen -->
12
+ <div id="loading-overlay">
13
+ <div class="loader-spinner"></div>
14
+ <div>INITIALIZING CASE FILE...</div>
15
+ </div>
16
+
17
+ <div id="game-container">
18
+
19
+ <!-- Header -->
20
+ <div id="top-bar">
21
+ <div class="game-title">MURDER.AI</div>
22
+ <div class="stats-panel">
23
+ <span>ROUND: <span id="round-display">1/5</span></span>
24
+ <span>POINTS: <span id="points-display">10</span></span>
25
+ </div>
26
+ </div>
27
+
28
+ <!-- Left: Suspect Files -->
29
+ <div id="suspect-files">
30
+ <!-- Populated by JS -->
31
+ </div>
32
+
33
+ <!-- Center: Evidence Board -->
34
+ <div id="evidence-board">
35
+ <!-- Red string SVG layer -->
36
+ <svg id="string-layer"></svg>
37
+ <!-- Evidence items populated by JS -->
38
+ </div>
39
+
40
+ <!-- Right: Tools -->
41
+ <div id="tools-panel">
42
+ <div class="tool-btn" id="tool-map">
43
+ <span class="tool-icon">πŸ—ΊοΈ</span>
44
+ <span>Location</span>
45
+ </div>
46
+ <div class="tool-btn" id="tool-camera">
47
+ <span class="tool-icon">πŸ“Ή</span>
48
+ <span>Footage</span>
49
+ </div>
50
+ <div class="tool-btn" id="tool-phone">
51
+ <span class="tool-icon">πŸ“ž</span>
52
+ <span>Call Alibi</span>
53
+ </div>
54
+ <div class="tool-btn" id="tool-dna">
55
+ <span class="tool-icon">πŸ”¬</span>
56
+ <span>DNA Test</span>
57
+ </div>
58
+ <div class="tool-btn" id="tool-accuse" style="margin-top: auto; border-color: red; color: red;">
59
+ <span class="tool-icon">βš–οΈ</span>
60
+ <span>ACCUSE</span>
61
+ </div>
62
+ </div>
63
+
64
+ <!-- Bottom: Chat / Log -->
65
+ <div id="chat-panel">
66
+ <div id="chat-log">
67
+ <div class="chat-message system">System initialized. Waiting for case data...</div>
68
+ </div>
69
+ <div id="chat-input-area">
70
+ <textarea id="chat-input" placeholder="Type your question here..."></textarea>
71
+ <button id="send-btn" class="send-btn">INTERROGATE</button>
72
+ </div>
73
+ </div>
74
+
75
+ </div>
76
+
77
+ <script src="../static/js/game_logic.js"></script>
78
+ </body>
79
+ </html>