@onnie81/murdoku-spor 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.dockerignore +7 -0
  2. package/Dockerfile +40 -0
  3. package/README.md +125 -0
  4. package/client/index.html +16 -0
  5. package/client/package.json +17 -0
  6. package/client/public/favicon.svg +14 -0
  7. package/client/public/manifest.webmanifest +12 -0
  8. package/client/src/App.jsx +62 -0
  9. package/client/src/api.js +40 -0
  10. package/client/src/art/logo.jsx +29 -0
  11. package/client/src/art/objects.jsx +158 -0
  12. package/client/src/art/portraits.jsx +197 -0
  13. package/client/src/components/Board.jsx +236 -0
  14. package/client/src/components/CluePanel.jsx +43 -0
  15. package/client/src/components/DailyView.jsx +114 -0
  16. package/client/src/components/FriendsView.jsx +112 -0
  17. package/client/src/components/HistoryView.jsx +80 -0
  18. package/client/src/components/HomeView.jsx +101 -0
  19. package/client/src/components/PlayView.jsx +323 -0
  20. package/client/src/components/SettingsView.jsx +134 -0
  21. package/client/src/components/Tray.jsx +48 -0
  22. package/client/src/difficulties.js +2 -0
  23. package/client/src/game.js +376 -0
  24. package/client/src/i18n/en.js +187 -0
  25. package/client/src/i18n/es.js +187 -0
  26. package/client/src/i18n/index.js +145 -0
  27. package/client/src/main.jsx +6 -0
  28. package/client/src/store.js +77 -0
  29. package/client/src/styles.css +374 -0
  30. package/client/vite.config.js +16 -0
  31. package/docker-compose.yml +21 -0
  32. package/murdoku.bundle +0 -0
  33. package/package.json +41 -0
  34. package/scripts/deploy.sh +49 -0
  35. package/scripts/npm-server-bootstrap.sh +53 -0
  36. package/scripts/npm-server-deploy.sh +61 -0
  37. package/scripts/server-bootstrap.sh +64 -0
  38. package/scripts/server-deploy.sh +40 -0
  39. package/server/api.js +354 -0
  40. package/server/auth.js +85 -0
  41. package/server/db.js +251 -0
  42. package/server/index.js +33 -0
  43. package/server/public/assets/index-COhad2G1.js +40 -0
  44. package/server/public/assets/index-h4AwMTqN.css +1 -0
  45. package/server/public/favicon.svg +14 -0
  46. package/server/public/index.html +17 -0
  47. package/server/public/manifest.webmanifest +12 -0
  48. package/server/puzzles.js +109 -0
  49. package/shared/bits.js +100 -0
  50. package/shared/clues.js +338 -0
  51. package/shared/generator.js +299 -0
  52. package/shared/model.js +127 -0
  53. package/shared/rng.js +51 -0
  54. package/shared/solver.js +262 -0
package/.dockerignore ADDED
@@ -0,0 +1,7 @@
1
+ node_modules
2
+ client/node_modules
3
+ server/public
4
+ data
5
+ .git
6
+ *.log
7
+ tests/artifacts
package/Dockerfile ADDED
@@ -0,0 +1,40 @@
1
+ # ---------- build stage ----------
2
+ FROM node:22-bookworm-slim AS build
3
+ WORKDIR /app
4
+
5
+ # toolchain fallback in case a native module has no prebuilt binary
6
+ RUN apt-get update \
7
+ && apt-get install -y --no-install-recommends python3 make g++ \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ COPY package.json ./
11
+ COPY client/package.json ./client/
12
+ RUN npm install --no-audit --no-fund && npm --prefix client install --no-audit --no-fund
13
+
14
+ COPY shared ./shared
15
+ COPY server ./server
16
+ COPY client ./client
17
+ RUN npm --prefix client run build \
18
+ && npm prune --omit=dev
19
+
20
+ # ---------- runtime stage ----------
21
+ FROM node:22-bookworm-slim
22
+ ENV NODE_ENV=production \
23
+ PORT=8080 \
24
+ DATA_DIR=/data
25
+ WORKDIR /app
26
+
27
+ COPY --from=build /app/package.json ./
28
+ COPY --from=build /app/node_modules ./node_modules
29
+ COPY --from=build /app/shared ./shared
30
+ COPY --from=build /app/server ./server
31
+
32
+ RUN mkdir -p /data && chown -R node:node /data /app
33
+ USER node
34
+ VOLUME /data
35
+ EXPOSE 8080
36
+
37
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
38
+ CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
39
+
40
+ CMD ["node", "server/index.js"]
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # Murdoku
2
+
3
+ A complete online murder-mystery placement-deduction game — the pencil-and-paper
4
+ "Murdoku" genre (Cluedo × Sudoku), fully procedural and multiplayer-social.
5
+
6
+ A victim and a cast of suspects must be placed on an illustrated crime scene:
7
+ **each row and each column holds exactly one person**, furniture blocks squares,
8
+ rooms and testimonies constrain everyone — and whoever ends up **alone with the
9
+ victim** is the killer.
10
+
11
+ ## Features
12
+
13
+ - **Procedural cases** — every board is generated: room layout, furniture,
14
+ ground truth, and a minimal clue set proven to have a **unique solution** that
15
+ is **solvable without blind guessing** (propagation + single-assumption logic).
16
+ - **5 difficulties** — Beginner 5×5 → Expert 9×9, graded by the number of
17
+ assumption rounds a logical solver needs, not just clue count.
18
+ - **Daily challenges** — same five seeded cases for the whole world (UTC),
19
+ with global and friends leaderboards, streaks, and a case archive.
20
+ - **Full play kit** — tap or drag placement, legal-move highlighting with
21
+ automatic graying of non-allowed squares, X marks, pencil marks (small
22
+ numbers or mini faces), clue spotlighting + strike-through, undo/redo,
23
+ hints, pause, mistake tracking, three assist levels.
24
+ - **Accounts, sessions, friends** — instant guest play (per-device session),
25
+ optional username+password accounts that adopt the guest history, friend
26
+ requests, and daily leaderboards. SQLite storage, one file.
27
+ - **History tracker** — per-user record: solved/played, streaks, best and
28
+ average times per difficulty, full case log; autosave + resume on any device.
29
+ - **Responsive + touch** — phone, tablet, desktop; pointer-based drag & drop,
30
+ long-press to mark X, keyboard shortcuts (1-9, N, X, U, R, E).
31
+ - **Multilingual** — English and Spanish shipped; clues are structured data
32
+ rendered through per-language templates, so adding a language is one file.
33
+ - **Original art** — 12 hand-drawn SVG suspect portraits, 16 furniture icons,
34
+ 8 scene themes, logo — no external assets, no fonts to load.
35
+
36
+ ## Quick start (Docker)
37
+
38
+ ```bash
39
+ docker compose up -d --build
40
+ # open http://localhost:8080
41
+ ```
42
+
43
+ Data (users, games, puzzles) lives in the `murdoku-data` volume as a single
44
+ SQLite file. Back it up with:
45
+
46
+ ```bash
47
+ docker run --rm -v murdoku-data:/data -v "$PWD:/out" debian \
48
+ cp /data/murdoku.db /out/murdoku-backup.db
49
+ ```
50
+
51
+ ### HTTPS / domain
52
+
53
+ Uncomment the `caddy` service in `docker-compose.yml`, set your domain, remove
54
+ the direct port mapping, and `docker compose up -d`. Caddy provisions TLS
55
+ automatically. (Any reverse proxy works; the app sets `trust proxy`.)
56
+
57
+ ### Configuration
58
+
59
+ | Env | Default | Meaning |
60
+ |---|---|---|
61
+ | `PORT` | `8080` | HTTP port |
62
+ | `DATA_DIR` | `/data` (container) / `./data` (bare) | SQLite location |
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ npm install && npm --prefix client install
68
+ npm run build # vite build -> server/public
69
+ npm run dev # serve on :8080
70
+ npm test # engine + API integration tests
71
+ node tests/e2e.mjs # Playwright visual/interaction smoke (needs chromium)
72
+ ```
73
+
74
+ Dev loop with hot reload: `npm run dev` in one terminal,
75
+ `npm --prefix client run dev` in another (Vite on :5173 proxies `/api`).
76
+
77
+ ## Architecture
78
+
79
+ ```
80
+ shared/ puzzle engine (isomorphic, seeded & deterministic)
81
+ rng.js xmur3 + mulberry32 seeded RNG
82
+ model.js cast, themes, difficulty bands, clue-type sets
83
+ clues.js scene context + bitmask tables, predicates, clue-pool gen
84
+ bits.js 96-bit bitset primitives
85
+ solver.js CSP: propagation (singles, arc revision, hidden singles,
86
+ room counts), uniqueness counting, "what-if" logical solver
87
+ generator.js rooms → furniture → ground truth (victim alone with exactly
88
+ one person) → clue selection/minimization → difficulty grading
89
+ server/ Express + better-sqlite3
90
+ db.js schema + queries (users, sessions, puzzles, games, friendships)
91
+ auth.js cookie sessions, bcrypt accounts, guest→account merge
92
+ puzzles.js daily seeding (UTC), free-play pregeneration pool
93
+ api.js REST: auth, settings, daily, games, history, stats,
94
+ friends, leaderboards
95
+ client/ React + Vite single-page app
96
+ game.js per-game store: placement, legality, notes, X, undo/redo,
97
+ autosave, accusation flow
98
+ components/ Board (SVG scene), Tray, CluePanel, Play/Home/Daily/
99
+ History/Friends/Settings views
100
+ i18n/ en/es dictionaries + structured clue renderer
101
+ art/ original SVG portraits, furniture, logo
102
+ ```
103
+
104
+ The daily seed is `daily:<UTC date>`; generation is fully deterministic, and
105
+ generated dailies are persisted, so every player gets the identical case.
106
+
107
+ **Honesty model:** the solution ships to the client to power instant assists
108
+ (wrong-placement flags, accusation feedback). Finishing is validated
109
+ server-side against the stored solution, but a determined user could read the
110
+ solution from devtools — fine for a friendly game, worth knowing for prizes.
111
+
112
+ ## Adding a language
113
+
114
+ Copy `client/src/i18n/en.js` to e.g. `fr.js`, translate the values (UI strings,
115
+ roles, rooms, objects, scenes, clue templates), and register it in
116
+ `client/src/i18n/index.js` (`DICTS`, `LANGS`). Rooms/objects carry `n` (label)
117
+ and `p` (in-sentence with article) forms so grammar stays natural.
118
+
119
+ ## Originality
120
+
121
+ Game mechanics follow the published Murdoku puzzle genre by Manuel Garand
122
+ (one person per row/column, map evidence, the murderer alone with the victim).
123
+ All code, characters, scenes, artwork, and text in this project are original.
124
+ "Murdoku" as a public-facing name/brand belongs to its owners — for a public
125
+ commercial deployment, consider your own title.
@@ -0,0 +1,16 @@
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, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#14121d" />
7
+ <meta name="description" content="Murdoku — murder-mystery placement puzzles. One person per row and column. Find who was alone with the victim." />
8
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
9
+ <link rel="manifest" href="/manifest.webmanifest" />
10
+ <title>Murdoku</title>
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.jsx"></script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "murdoku-client",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build"
8
+ },
9
+ "dependencies": {
10
+ "react": "^18.3.1",
11
+ "react-dom": "^18.3.1"
12
+ },
13
+ "devDependencies": {
14
+ "@vitejs/plugin-react": "^4.3.1",
15
+ "vite": "^5.4.0"
16
+ }
17
+ }
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
2
+ <rect width="100" height="100" rx="20" fill="#14121d"/>
3
+ <rect x="14" y="14" width="56" height="56" rx="8" fill="#2b2440" stroke="#c9a227" stroke-width="4"/>
4
+ <g stroke="#c9a227" stroke-width="3" opacity="0.75">
5
+ <line x1="33" y1="16" x2="33" y2="68"/>
6
+ <line x1="51" y1="16" x2="51" y2="68"/>
7
+ <line x1="16" y1="33" x2="68" y2="33"/>
8
+ <line x1="16" y1="51" x2="68" y2="51"/>
9
+ </g>
10
+ <circle cx="42" cy="42" r="24" fill="#00000066" stroke="#efe6d0" stroke-width="6.5"/>
11
+ <path d="M60,62 L84,88" stroke="#efe6d0" stroke-width="10" stroke-linecap="round"/>
12
+ <path d="M60,62 L84,88" stroke="#8d1d2c" stroke-width="4.5" stroke-linecap="round"/>
13
+ <path d="M34,34 L50,50 M50,34 L34,50" stroke="#e5484d" stroke-width="5.5" stroke-linecap="round"/>
14
+ </svg>
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "Murdoku",
3
+ "short_name": "Murdoku",
4
+ "description": "Murder-mystery placement puzzles. One person per row and column — find who was alone with the victim.",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#14121d",
8
+ "theme_color": "#14121d",
9
+ "icons": [
10
+ { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
11
+ ]
12
+ }
@@ -0,0 +1,62 @@
1
+ import React, { useEffect, useSyncExternalStore } from 'react';
2
+ import { useApp, initApp, navigate } from './store.js';
3
+ import { onLangChange, getLang, t } from './i18n/index.js';
4
+ import { HomeView } from './components/HomeView.jsx';
5
+ import { PlayView } from './components/PlayView.jsx';
6
+ import { DailyView } from './components/DailyView.jsx';
7
+ import { HistoryView } from './components/HistoryView.jsx';
8
+ import { FriendsView } from './components/FriendsView.jsx';
9
+ import { SettingsView } from './components/SettingsView.jsx';
10
+ import { GlassMark } from './art/logo.jsx';
11
+
12
+ function useLang() {
13
+ return useSyncExternalStore(onLangChange, getLang);
14
+ }
15
+
16
+ const NAV = [
17
+ ['#/', 'home', '⌂'],
18
+ ['#/daily', 'daily', '☀'],
19
+ ['#/history', 'history', '▤'],
20
+ ['#/friends', 'friends', '☺'],
21
+ ['#/settings', 'settings', '⚙']
22
+ ];
23
+
24
+ export default function App() {
25
+ const app = useApp();
26
+ const lang = useLang();
27
+
28
+ useEffect(() => { initApp(); }, []);
29
+
30
+ if (!app.ready) return <div className="boot"><GlassMark size={64} /></div>;
31
+
32
+ const { route } = app;
33
+ const inPlay = route.view === 'play';
34
+
35
+ return (
36
+ <div className={`app${inPlay ? ' app-play' : ''}`} key={lang}>
37
+ <main className="main">
38
+ {route.view === 'home' && <HomeView />}
39
+ {route.view === 'play' && <PlayView gameId={route.gameId} />}
40
+ {route.view === 'daily' && <DailyView />}
41
+ {route.view === 'history' && <HistoryView />}
42
+ {route.view === 'friends' && <FriendsView />}
43
+ {route.view === 'settings' && <SettingsView />}
44
+ </main>
45
+
46
+ {!inPlay && (
47
+ <nav className="nav">
48
+ {NAV.map(([hash, key, icon]) => (
49
+ <button key={key}
50
+ className={route.view === key || (key === 'home' && route.view === 'home') ? 'on' : ''}
51
+ onClick={() => navigate(hash)}>
52
+ <i aria-hidden>{icon}</i>
53
+ <span>{t(key)}</span>
54
+ </button>
55
+ ))}
56
+ </nav>
57
+ )}
58
+
59
+ {app.toast && <div className="toast" role="status">{app.toast}</div>}
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1,40 @@
1
+ async function req(path, opts = {}) {
2
+ const res = await fetch('/api' + path, {
3
+ method: opts.method || 'GET',
4
+ headers: opts.body != null ? { 'content-type': 'application/json' } : undefined,
5
+ body: opts.body != null ? JSON.stringify(opts.body) : undefined,
6
+ credentials: 'same-origin',
7
+ keepalive: !!opts.keepalive
8
+ });
9
+ let body = null;
10
+ try { body = await res.json(); } catch { /* empty */ }
11
+ if (!res.ok) {
12
+ const err = new Error(body?.error || `http_${res.status}`);
13
+ err.status = res.status;
14
+ err.code = body?.error;
15
+ throw err;
16
+ }
17
+ return body;
18
+ }
19
+
20
+ export const api = {
21
+ me: () => req('/me'),
22
+ register: (username, password) => req('/auth/register', { method: 'POST', body: { username, password } }),
23
+ login: (username, password) => req('/auth/login', { method: 'POST', body: { username, password } }),
24
+ logout: () => req('/auth/logout', { method: 'POST' }),
25
+ saveSettings: (settings) => req('/settings', { method: 'PUT', body: settings }),
26
+ daily: (date) => req('/daily' + (date ? `?date=${date}` : '')),
27
+ newGame: (mode, difficulty, date) => req('/games', { method: 'POST', body: { mode, difficulty, date } }),
28
+ getGame: (id) => req(`/games/${id}`),
29
+ saveGame: (id, payload, keepalive = false) => req(`/games/${id}`, { method: 'PUT', body: payload, keepalive }),
30
+ finishGame: (id, payload) => req(`/games/${id}/finish`, { method: 'POST', body: payload }),
31
+ abandonGame: (id) => req(`/games/${id}/abandon`, { method: 'POST' }),
32
+ active: () => req('/active'),
33
+ history: (limit = 60, offset = 0) => req(`/history?limit=${limit}&offset=${offset}`),
34
+ stats: () => req('/stats'),
35
+ friends: () => req('/friends'),
36
+ friendRequest: (username) => req('/friends/request', { method: 'POST', body: { username } }),
37
+ friendRespond: (userId, accept) => req('/friends/respond', { method: 'POST', body: { userId, accept } }),
38
+ friendRemove: (userId) => req(`/friends/${userId}`, { method: 'DELETE' }),
39
+ leaderboard: (date, difficulty, scope) => req(`/leaderboard?date=${date}&difficulty=${difficulty}&scope=${scope}`)
40
+ };
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+
3
+ export function Wordmark({ size = 1 }) {
4
+ return (
5
+ <div className="wordmark" style={{ fontSize: `${size}em` }}>
6
+ <GlassMark />
7
+ <span className="wordmark-text">MURDOKU</span>
8
+ </div>
9
+ );
10
+ }
11
+
12
+ export function GlassMark({ size = 44 }) {
13
+ return (
14
+ <svg viewBox="0 0 100 100" width={size} height={size} className="glassmark" role="img" aria-label="Murdoku">
15
+ <rect x="8" y="8" width="60" height="60" rx="10" fill="#2b2440" stroke="#c9a227" strokeWidth="4" />
16
+ <g stroke="#c9a227" strokeWidth="3" opacity="0.75">
17
+ <line x1="28" y1="10" x2="28" y2="66" />
18
+ <line x1="48" y1="10" x2="48" y2="66" />
19
+ <line x1="10" y1="28" x2="66" y2="28" />
20
+ <line x1="10" y1="48" x2="66" y2="48" />
21
+ </g>
22
+ <circle cx="38" cy="38" r="26" fill="#00000055" stroke="#efe6d0" strokeWidth="7" />
23
+ <circle cx="38" cy="38" r="26" fill="none" stroke="#c9a227" strokeWidth="2.5" />
24
+ <path d="M57,60 L82,86" stroke="#efe6d0" strokeWidth="11" strokeLinecap="round" />
25
+ <path d="M57,60 L82,86" stroke="#8d1d2c" strokeWidth="5" strokeLinecap="round" />
26
+ <path d="M30,30 L46,46 M46,30 L30,46" stroke="#e5484d" strokeWidth="6" strokeLinecap="round" />
27
+ </svg>
28
+ );
29
+ }
@@ -0,0 +1,158 @@
1
+ // Original furniture / object icons, drawn to sit inside a board cell.
2
+ import React from 'react';
3
+
4
+ const S = { fill: 'none', stroke: 'currentColor', strokeWidth: 5, strokeLinecap: 'round', strokeLinejoin: 'round' };
5
+ const F = { fill: 'currentColor', stroke: 'none' };
6
+
7
+ const ICONS = {
8
+ piano: (
9
+ <>
10
+ <path {...F} d="M18,30 L66,30 Q84,34 84,52 Q84,70 62,70 L18,70 Z" opacity="0.85" />
11
+ <rect {...F} x="18" y="30" width="10" height="40" opacity="0.5" />
12
+ <path {...S} strokeWidth="3" d="M28,36 L28,64" opacity="0.6" />
13
+ <path {...F} d="M18,74 L60,74 L60,80 L18,80 Z" opacity="0.5" />
14
+ </>
15
+ ),
16
+ armchair: (
17
+ <>
18
+ <path {...F} d="M26,34 Q50,24 74,34 L74,58 L26,58 Z" opacity="0.75" />
19
+ <rect {...F} x="16" y="46" width="14" height="26" rx="6" opacity="0.9" />
20
+ <rect {...F} x="70" y="46" width="14" height="26" rx="6" opacity="0.9" />
21
+ <rect {...F} x="26" y="58" width="48" height="14" rx="4" opacity="0.6" />
22
+ </>
23
+ ),
24
+ bookshelf: (
25
+ <>
26
+ <rect {...S} x="24" y="18" width="52" height="64" />
27
+ <line {...S} strokeWidth="4" x1="24" y1="40" x2="76" y2="40" />
28
+ <line {...S} strokeWidth="4" x1="24" y1="60" x2="76" y2="60" />
29
+ {[30, 37, 44, 52, 59, 66].map((x, i) => (
30
+ <rect key={i} {...F} x={x} y={i % 2 ? 24 : 26} width="5" height={i % 2 ? 13 : 11} opacity="0.7" />
31
+ ))}
32
+ {[32, 41, 50, 59, 66].map((x, i) => (
33
+ <rect key={i} {...F} x={x} y={i % 2 ? 45 : 47} width="5" height={i % 2 ? 12 : 10} opacity="0.55" />
34
+ ))}
35
+ </>
36
+ ),
37
+ plant: (
38
+ <>
39
+ <path {...F} d="M34,62 L66,62 L60,84 L40,84 Z" opacity="0.8" />
40
+ <path {...S} strokeWidth="4" d="M50,62 L50,42" />
41
+ <path {...F} d="M50,44 Q34,42 30,26 Q48,28 50,44 Z" opacity="0.7" />
42
+ <path {...F} d="M50,44 Q66,42 70,26 Q52,28 50,44 Z" opacity="0.7" />
43
+ <path {...F} d="M50,38 Q50,20 50,16 Q58,26 50,40 Z" opacity="0.7" />
44
+ </>
45
+ ),
46
+ clock: (
47
+ <>
48
+ <rect {...S} x="34" y="14" width="32" height="72" rx="4" />
49
+ <circle {...S} strokeWidth="4" cx="50" cy="32" r="11" />
50
+ <path {...S} strokeWidth="3" d="M50,32 L50,25 M50,32 L55,34" />
51
+ <line {...S} strokeWidth="3" x1="50" y1="48" x2="50" y2="70" opacity="0.7" />
52
+ <circle {...F} cx="50" cy="72" r="5" opacity="0.8" />
53
+ </>
54
+ ),
55
+ chest: (
56
+ <>
57
+ <path {...F} d="M22,42 Q22,26 50,26 Q78,26 78,42 L78,50 L22,50 Z" opacity="0.75" />
58
+ <rect {...F} x="22" y="50" width="56" height="26" rx="3" opacity="0.9" />
59
+ <rect {...F} x="45" y="44" width="10" height="14" rx="2" opacity="0.5" />
60
+ <line {...S} strokeWidth="4" x1="32" y1="28" x2="32" y2="76" opacity="0.45" />
61
+ <line {...S} strokeWidth="4" x1="68" y1="28" x2="68" y2="76" opacity="0.45" />
62
+ </>
63
+ ),
64
+ stove: (
65
+ <>
66
+ <rect {...F} x="24" y="38" width="52" height="42" rx="4" opacity="0.85" />
67
+ <rect {...S} strokeWidth="4" x="32" y="48" width="24" height="22" opacity="0.7" />
68
+ <circle {...F} cx="68" cy="46" r="3.5" opacity="0.6" />
69
+ <circle {...F} cx="68" cy="56" r="3.5" opacity="0.6" />
70
+ <path {...S} strokeWidth="5" d="M62,38 L62,22 Q62,16 68,16" opacity="0.8" />
71
+ </>
72
+ ),
73
+ bed: (
74
+ <>
75
+ <path {...S} d="M20,32 L20,76 M80,44 L80,76" />
76
+ <path {...F} d="M20,52 L80,52 L80,66 L20,66 Z" opacity="0.7" />
77
+ <rect {...F} x="24" y="44" width="18" height="9" rx="4" opacity="0.9" />
78
+ <path {...F} d="M20,32 Q26,26 32,32 L32,44 L20,44 Z" opacity="0.5" />
79
+ </>
80
+ ),
81
+ desk: (
82
+ <>
83
+ <rect {...F} x="18" y="36" width="64" height="8" rx="2" opacity="0.9" />
84
+ <rect {...S} strokeWidth="4" x="24" y="44" width="22" height="18" opacity="0.8" />
85
+ <circle {...F} cx="35" cy="53" r="2.5" opacity="0.8" />
86
+ <path {...S} d="M76,44 L76,78 M24,62 L24,78" />
87
+ <rect {...F} x="52" y="26" width="20" height="10" rx="1" opacity="0.5" transform="rotate(-6 62 31)" />
88
+ </>
89
+ ),
90
+ statue: (
91
+ <>
92
+ <rect {...F} x="32" y="70" width="36" height="12" opacity="0.85" />
93
+ <rect {...F} x="38" y="62" width="24" height="8" opacity="0.6" />
94
+ <path {...F} d="M44,62 Q40,48 46,40 Q42,34 47,28 Q44,20 50,18 Q56,20 53,28 Q58,34 54,40 Q60,48 56,62 Z" opacity="0.8" />
95
+ </>
96
+ ),
97
+ telescope: (
98
+ <>
99
+ <path {...F} d="M28,58 L64,26 L72,36 L36,68 Z" opacity="0.85" />
100
+ <path {...F} d="M62,20 L78,34 L74,39 L58,25 Z" opacity="0.6" />
101
+ <path {...S} d="M46,60 L36,84 M46,60 L58,84" />
102
+ <circle {...F} cx="47" cy="59" r="6" opacity="0.9" />
103
+ </>
104
+ ),
105
+ lamp: (
106
+ <>
107
+ <path {...F} d="M34,18 L66,18 L58,42 L42,42 Z" opacity="0.8" />
108
+ <line {...S} x1="50" y1="42" x2="50" y2="78" />
109
+ <path {...S} d="M36,84 Q50,76 64,84" />
110
+ </>
111
+ ),
112
+ mirror: (
113
+ <>
114
+ <ellipse {...S} cx="50" cy="42" rx="20" ry="26" />
115
+ <ellipse {...F} cx="50" cy="42" rx="14" ry="20" opacity="0.25" />
116
+ <path {...S} strokeWidth="3" d="M42,36 Q46,28 54,28" opacity="0.7" />
117
+ <path {...S} d="M40,70 L36,84 M60,70 L64,84 M38,80 L62,80" />
118
+ </>
119
+ ),
120
+ painting: (
121
+ <>
122
+ <rect {...S} x="22" y="24" width="56" height="46" />
123
+ <rect {...S} strokeWidth="2.5" x="29" y="31" width="42" height="32" opacity="0.6" />
124
+ <path {...S} strokeWidth="3" d="M32,56 L44,42 L52,50 L60,38 L68,50" opacity="0.8" />
125
+ <circle {...F} cx="62" cy="38" r="3" opacity="0.6" />
126
+ </>
127
+ ),
128
+ crate: (
129
+ <>
130
+ <rect {...S} x="24" y="28" width="52" height="48" />
131
+ <line {...S} strokeWidth="4" x1="24" y1="44" x2="76" y2="44" opacity="0.8" />
132
+ <line {...S} strokeWidth="4" x1="24" y1="60" x2="76" y2="60" opacity="0.8" />
133
+ <rect {...F} x="40" y="32" width="20" height="7" rx="2" opacity="0.6" />
134
+ <circle {...F} cx="31" cy="52" r="2.5" opacity="0.7" />
135
+ <circle {...F} cx="69" cy="52" r="2.5" opacity="0.7" />
136
+ <circle {...F} cx="31" cy="68" r="2.5" opacity="0.7" />
137
+ <circle {...F} cx="69" cy="68" r="2.5" opacity="0.7" />
138
+ </>
139
+ ),
140
+ harp: (
141
+ <>
142
+ <path {...S} d="M30,80 L30,30 Q30,14 48,16 Q68,18 70,44 L70,80" />
143
+ <path {...F} d="M26,78 L74,78 L70,86 L30,86 Z" opacity="0.85" />
144
+ {[38, 46, 54, 62].map((x, i) => (
145
+ <line key={i} {...S} strokeWidth="2.5" x1={x} y1={26 + i * 3} x2={x} y2="78" opacity="0.7" />
146
+ ))}
147
+ </>
148
+ )
149
+ };
150
+
151
+ export function ObjectIcon({ type, size = 40, className = '', title }) {
152
+ return (
153
+ <svg viewBox="0 0 100 100" width={size} height={size} className={`obj-icon ${className}`}
154
+ role="img" aria-label={title || type}>
155
+ {ICONS[type] || <circle {...S} cx="50" cy="50" r="26" />}
156
+ </svg>
157
+ );
158
+ }