Skip to content

Commit abf23d5

Browse files
CaYaturclaude
andcommitted
Phase 7: player inventory viewer (NBT + online item icons)
Verified: smoke writes a real playerdata .dat and asserts the inventory parses (diamond_sword extracted). - Read Inventory from player NBT (handles pre-1.20.5 Count + 1.20.5+ count) - Inventory grid in the player detail modal; item/block icons from an online, updatable source (assets.mcasset.cloud) with item->block->text fallback - README updated for the big update; web panel + store marked beta / off by default Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6e25740 commit abf23d5

9 files changed

Lines changed: 180 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,20 @@ create, configure, monitor and control servers from a clean graphical interface.
4949
| Cron scheduler (restart / backup / command / broadcast) | ✅ |
5050
| Crash analyzer (known-pattern detection with fixes) | ✅ |
5151
| CI + automated portable-exe releases + in-app update check | ✅ |
52-
53-
> Notes: Forge/NeoForge run their official installer (`--installServer`) on create.
54-
> Spigot is intentionally not one-click (it requires BuildTools compilation).
52+
| Syntax-highlighted file editor (CodeMirror) with tabs + split view | ✅ |
53+
| Player inventory viewer (NBT) with online item icons | ✅ |
54+
| Splash screen + CaYaDev branding | ✅ |
55+
| **Web panel** — bearer-auth, per-server RBAC, mobile-friendly (off by default) | ✅ (beta) |
56+
| **Store / economy** — currency, items + crates (animated), in-game delivery | ✅ (beta) |
57+
58+
> Notes:
59+
> - Forge/NeoForge run their official installer (`--installServer`) on create;
60+
> Spigot is intentionally not one-click (requires BuildTools compilation).
61+
> - The web panel is **off by default** and binds to `127.0.0.1`. LAN/mobile
62+
> access is an explicit opt-in and has **no HTTPS** — only enable on a trusted
63+
> network. Web panel + store are marked **beta**.
64+
> - A visual website/CMS builder (page design, posts) is planned; the store/
65+
> economy core (products, crates, delivery, balances) is implemented.
5566
5667
### 🚀 Getting started (development)
5768
```bash

‎src/main/core/players.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ async function readPlayerData(file: string, p: PlayerInfo): Promise<void> {
5858
if (v?.Health) p.health = Math.round(tag(v.Health))
5959
if (v?.foodLevel) p.food = tag(v.foodLevel)
6060
if (v?.XpLevel) p.xpLevel = tag(v.XpLevel)
61+
// Inventory (list of item compounds). Handles pre-1.20.5 (Count) and
62+
// 1.20.5+ (count) shapes.
63+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
64+
const inv = v?.Inventory?.value?.value as any[]
65+
if (Array.isArray(inv)) {
66+
p.inventory = inv
67+
.map((it) => ({
68+
slot: tag(it.Slot) ?? tag(it.slot) ?? -1,
69+
id: String(tag(it.id) ?? '').replace('minecraft:', ''),
70+
count: tag(it.Count) ?? tag(it.count) ?? 1
71+
}))
72+
.filter((x) => x.id)
73+
}
6174
p.lastSeen = statSync(file).mtimeMs
6275
} catch {
6376
/* ignore unreadable */

‎src/main/smoke.ts‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { app, BrowserWindow } from 'electron'
2-
import { existsSync } from 'node:fs'
2+
import { existsSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
33
import { join } from 'node:path'
4+
import * as nbt from 'prismarine-nbt'
45
import { processManager } from './core/processManager'
56
import { getConfig, updateConfig } from './config'
67
import { startWebServer, stopWebServer } from './web/server'
@@ -192,6 +193,50 @@ export async function runSmoke(): Promise<void> {
192193
if (!steve || !steve.op) return fail('players: op merge failed')
193194
console.log('SMOKE: rcon-enable + players merge OK')
194195

196+
// --- 2d. inventory NBT parse (write a real playerdata .dat) ---
197+
const spath = getConfig().servers.find((s) => s.id === id)?.path ?? ''
198+
const invUuid = '22222222-2222-2222-2222-222222222222'
199+
const pdDir = join(spath, 'world', 'playerdata')
200+
mkdirSync(pdDir, { recursive: true })
201+
const datBuf = nbt.writeUncompressed(
202+
{
203+
type: 'compound',
204+
name: '',
205+
value: {
206+
Health: { type: 'float', value: 20 },
207+
Inventory: {
208+
type: 'list',
209+
value: {
210+
type: 'compound',
211+
value: [
212+
{
213+
Slot: { type: 'byte', value: 0 },
214+
id: { type: 'string', value: 'minecraft:diamond_sword' },
215+
Count: { type: 'byte', value: 1 }
216+
}
217+
]
218+
}
219+
}
220+
}
221+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
222+
} as any,
223+
'big'
224+
)
225+
writeFileSync(join(pdDir, invUuid + '.dat'), datBuf)
226+
sf.writeTextFile(id, 'usercache.json', JSON.stringify([{ name: 'InvTester', uuid: invUuid }]))
227+
const players2 = await playersMod.getPlayers(id)
228+
const invp = players2.find((p) => p.name === 'InvTester')
229+
sf.deleteEntry(id, 'usercache.json')
230+
try {
231+
rmSync(join(spath, 'world'), { recursive: true, force: true })
232+
} catch {
233+
/* ignore */
234+
}
235+
if (!invp?.inventory?.some((it) => it.id === 'diamond_sword')) {
236+
return fail('inventory NBT not parsed')
237+
}
238+
console.log('SMOKE: inventory NBT parse OK')
239+
195240
// --- 3. command over stdin ---
196241
processManager.sendCommand(id, 'say hello-from-smoke')
197242
await sleep(400)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { useState, useEffect } from 'react'
2+
3+
/**
4+
* Minecraft item/block icon from an online, always-updatable source
5+
* (assets.mcasset.cloud, which mirrors Mojang textures by version).
6+
* Tries item/ then block/, then falls back to a text chip.
7+
*/
8+
export function ItemIcon({
9+
id,
10+
version,
11+
size = 32
12+
}: {
13+
id: string
14+
version?: string
15+
size?: number
16+
}): JSX.Element {
17+
const ver = version && /^1\.\d+(\.\d+)?$/.test(version) ? version : '1.21.4'
18+
const [stage, setStage] = useState(0) // 0=item, 1=block, 2=text
19+
useEffect(() => setStage(0), [id, ver])
20+
21+
if (stage >= 2) {
22+
return (
23+
<div className="item-fallback" style={{ width: size, height: size }} title={id}>
24+
{id.slice(0, 3)}
25+
</div>
26+
)
27+
}
28+
const folder = stage === 0 ? 'item' : 'block'
29+
const url = `https://assets.mcasset.cloud/${ver}/assets/minecraft/textures/${folder}/${id}.png`
30+
return (
31+
<img
32+
className="item-icon"
33+
src={url}
34+
width={size}
35+
height={size}
36+
alt={id}
37+
title={id}
38+
loading="lazy"
39+
style={{ imageRendering: 'pixelated' }}
40+
onError={() => setStage((s) => s + 1)}
41+
/>
42+
)
43+
}

‎src/renderer/src/locales/en.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export default {
187187
position: 'Position',
188188
inventory: 'Inventory',
189189
inventorySoon: 'Live inventory viewer is coming in a later update.',
190+
noInventory: 'No saved inventory (the player must have logged in and the world saved). Icons load from an online source.',
190191
clickHint: 'Click a player card for details and actions.',
191192
offline: 'Offline'
192193
},

‎src/renderer/src/locales/tr.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ const tr: typeof en = {
190190
position: 'Konum',
191191
inventory: 'Envanter',
192192
inventorySoon: 'Canlı envanter görüntüleyici sonraki güncellemede gelecek.',
193+
noInventory: 'Kayıtlı envanter yok (oyuncu giriş yapmış ve dünya kaydedilmiş olmalı). Simgeler çevrimiçi kaynaktan yüklenir.',
193194
clickHint: 'Ayrıntılar ve işlemler için bir oyuncu kartına tıklayın.',
194195
offline: 'Çevrimdışı'
195196
},

‎src/renderer/src/styles.css‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,47 @@ textarea.input:focus {
10511051
color: var(--text);
10521052
}
10531053

1054+
/* ---------- Inventory grid ---------- */
1055+
.inv-grid {
1056+
display: grid;
1057+
grid-template-columns: repeat(9, 1fr);
1058+
gap: 4px;
1059+
}
1060+
.inv-slot {
1061+
position: relative;
1062+
aspect-ratio: 1;
1063+
display: grid;
1064+
place-items: center;
1065+
background: var(--bg-input);
1066+
border: 1px solid var(--border);
1067+
border-radius: 6px;
1068+
}
1069+
.inv-count {
1070+
position: absolute;
1071+
right: 2px;
1072+
bottom: 1px;
1073+
font-size: 10px;
1074+
font-weight: 700;
1075+
color: #fff;
1076+
text-shadow: 1px 1px 0 #000;
1077+
}
1078+
.item-icon {
1079+
image-rendering: pixelated;
1080+
}
1081+
.item-fallback {
1082+
display: grid;
1083+
place-items: center;
1084+
font-size: 9px;
1085+
color: var(--text-faint);
1086+
text-transform: lowercase;
1087+
overflow: hidden;
1088+
}
1089+
@media (max-width: 560px) {
1090+
.inv-grid {
1091+
grid-template-columns: repeat(6, 1fr);
1092+
}
1093+
}
1094+
10541095
/* ---------- Mods / backups / scheduler rows ---------- */
10551096
.mod-row {
10561097
display: flex;

‎src/renderer/src/views/PlayersView.tsx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from 'lucide-react'
2424
import { useStore } from '../store'
2525
import { PlayerAvatar } from '../components/PlayerAvatar'
26+
import { ItemIcon } from '../components/ItemIcon'
2627
import type { PlayerInfo } from '@shared/types'
2728

2829
const GAMEMODES = ['survival', 'creative', 'adventure', 'spectator']
@@ -74,6 +75,7 @@ export function PlayersView(): JSX.Element {
7475
const { t } = useTranslation()
7576
const id = useStore((s) => s.activeServerId) as string
7677
const status = useStore((s) => s.activeStatus().status)
78+
const mcVersion = useStore((s) => s.activeServer()?.mcVersion)
7779
const toast = useStore((s) => s.toast)
7880
const [list, setList] = useState<PlayerInfo[]>([])
7981
const [selected, setSelected] = useState<PlayerInfo | null>(null)
@@ -223,7 +225,18 @@ export function PlayersView(): JSX.Element {
223225
<Package size={13} style={{ verticalAlign: -2, marginRight: 6 }} />
224226
{t('players.inventory')}
225227
</div>
226-
<p className="hint" style={{ marginTop: 0 }}>{t('players.inventorySoon')}</p>
228+
{selected.inventory && selected.inventory.length > 0 ? (
229+
<div className="inv-grid">
230+
{selected.inventory.map((it, i) => (
231+
<div className="inv-slot" key={i} title={`${it.id} ×${it.count}`}>
232+
<ItemIcon id={it.id} version={mcVersion} size={30} />
233+
{it.count > 1 && <span className="inv-count">{it.count}</span>}
234+
</div>
235+
))}
236+
</div>
237+
) : (
238+
<p className="hint" style={{ marginTop: 0 }}>{t('players.noInventory')}</p>
239+
)}
227240
</div>
228241
</div>
229242
)}

‎src/shared/types.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ export interface PlayerInfo {
175175
lastSeen?: number
176176
playtimeHours?: number
177177
ip?: string
178+
inventory?: InventoryItem[]
179+
}
180+
181+
export interface InventoryItem {
182+
slot: number
183+
id: string
184+
count: number
178185
}
179186

180187
export type PlayerAction =

0 commit comments

Comments
 (0)