feat(scum): add local game player intelligence

This commit is contained in:
npc0-hue
2026-07-28 15:23:09 +08:00
parent c5806cc6b7
commit 73947edcee
26 changed files with 1209 additions and 65 deletions
+11
View File
@@ -48,6 +48,8 @@ import type {
GameClientBridgeSnapshotQuery,
GameClientBridgeStatusResponse,
GamePluginListResponse,
GamePlayerListResponse,
GamePlayerProfileResponse,
HealthResponse,
JobCreateRequest,
JobListResponse,
@@ -574,6 +576,15 @@ export class PlatformApiClient {
return this.request<BackupListResponse>(`/backups?serverInstanceId=${encodeURIComponent(serverInstanceId)}`);
}
async listGamePlayers(serverInstanceId: string, search = ""): Promise<GamePlayerListResponse> {
const query = search ? `?search=${encodeURIComponent(search)}` : "";
return this.request<GamePlayerListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players${query}`);
}
async getGamePlayerProfile(serverInstanceId: string, playerId: string): Promise<GamePlayerProfileResponse> {
return this.request<GamePlayerProfileResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}`);
}
async getBackup(id: string): Promise<BackupResponse> {
return this.request<BackupResponse>(`/backups/${encodeURIComponent(id)}`);
}
+7
View File
@@ -297,6 +297,13 @@ export interface RuntimeLogEventResponse {
retentionDays: number;
severity: "info" | "notice" | "warning" | "critical";
}
export interface GamePlayerResponse { id: string; serverInstanceId: string; gamePlayerId: string; displayName: string; firstSeenAt: string; lastSeenAt: string; }
export interface GamePlayerAliasResponse { alias: string; firstSeenAt: string; lastSeenAt: string; }
export interface GamePlayerSessionResponse { id: string; sourceSessionId: string; startedAt: string; endedAt?: string; endReason?: string; }
export interface GameAccessAttemptResponse { id: string; occurredAt: string; outcome: string; reason: string; }
export interface GameSecuritySignalResponse { id: string; ruleKey: string; status: "open" | "review-required" | "expired"; evidenceCount: number; summary: string; firstObservedAt: string; lastObservedAt: string; }
export interface GamePlayerProfileResponse { player: GamePlayerResponse; aliases: GamePlayerAliasResponse[]; sessions: GamePlayerSessionResponse[]; accessAttempts: GameAccessAttemptResponse[]; securitySignals: GameSecuritySignalResponse[]; }
export interface GamePlayerListResponse { items: GamePlayerResponse[]; }
export interface RuntimeTransportProfileResponse {
key: string;
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import source from "./GamePlayerIntelligencePanel.tsx?raw";
describe("GamePlayerIntelligencePanel", () => {
it("renders reviewable labels without raw network material or enforcement controls", () => {
expect(source).toContain("需要人工审核");
expect(source).toContain("失败尝试");
expect(source).not.toContain("networkFingerprint");
expect(source).not.toMatch(/\bban\b|\bkick\b/i);
});
});
@@ -0,0 +1,20 @@
import { RefreshCw, ShieldAlert, UsersRound } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GamePlayerProfileResponse, GamePlayerResponse } from "../api/types";
import { ErrorState, LoadingState } from "./StateViews";
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; players: GamePlayerResponse[]; selected?: GamePlayerProfileResponse };
export function GamePlayerIntelligencePanel({ serverInstanceId }: { serverInstanceId: string }) {
const [state, setState] = useState<State>({ status: "loading" });
const load = useCallback(async () => { setState({ status: "loading" }); try { const result = await platformApiClient.listGamePlayers(serverInstanceId); setState({ status: "ready", players: result.items }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "玩家档案读取失败。" }); } }, [serverInstanceId]);
useEffect(() => { void load(); }, [load]);
async function select(player: GamePlayerResponse) { try { const selected = await platformApiClient.getGamePlayerProfile(serverInstanceId, player.id); setState((current) => current.status === "ready" ? { ...current, selected } : current); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "玩家档案读取失败。" }); } }
if (state.status === "loading") return <LoadingState label="正在加载本地游戏玩家档案…" />;
if (state.status === "error") return <ErrorState title="玩家档案不可用" reason={state.reason} onRetry={() => void load()} />;
const profile = state.selected;
return <section className="console-panel" aria-label="SCUM 本地玩家档案与风险信号"><div className="panel-header"><div><h2><UsersRound size={16} /> </h2><p className="provider-id"></p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span></span></button></div><div className="console-record-list">{state.players.length ? state.players.map((player) => <button type="button" className="console-record" key={player.id} onClick={() => void select(player)}><strong>{player.displayName}</strong><span> ID{player.gamePlayerId}</span><small>{formatTime(player.lastSeenAt)}</small></button>) : <p className="page-status"></p>}</div>{profile && <div className="console-module"><div className="panel-header"><h3>{profile.player.displayName} </h3><span className="page-status"> {profile.aliases.length} · {profile.sessions.length}</span></div><div className="console-row-list"><div className="console-row"><strong></strong><span>{profile.aliases.map((alias) => alias.alias).join(" / ") || "无"}</span></div>{profile.sessions.slice(0, 10).map((session) => <div className="console-row" key={session.id}><strong></strong><span>{formatTime(session.startedAt)} {session.endedAt ? formatTime(session.endedAt) : "仍在线"} {session.endReason ? `(${session.endReason})` : ""}</span></div>)}</div><div className="console-record-list">{profile.securitySignals.length ? profile.securitySignals.map((signal) => <div className="console-record" key={signal.id}><ShieldAlert size={15} aria-hidden="true" /><strong>{signal.ruleKey}</strong><span>{signal.status === "review-required" ? "需要人工审核" : signal.status}</span><small>{signal.summary} · {signal.evidenceCount} </small></div>) : <p className="page-status"></p>}</div>{profile.accessAttempts.length > 0 && <p className="page-status">{profile.accessAttempts.length} </p>}</div>}</section>;
}
function formatTime(value: string) { const date = new Date(value); return Number.isNaN(date.valueOf()) ? "--" : date.toLocaleString("zh-CN", { hour12: false }); }
@@ -5,6 +5,7 @@ import { platformApiClient } from "../api/client";
import type { GamePluginResponse } from "../api/types";
import { PageFrame } from "../components/PageFrame";
import { ScumFileConfigWorkbench } from "../components/ScumFileConfigWorkbench";
import { GamePlayerIntelligencePanel } from "../components/GamePlayerIntelligencePanel";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
@@ -106,6 +107,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
)}
</section>
{scumResolution?.available && state.plugin.fileWorkspace && <ScumFileConfigWorkbench contract={scumResolution.contract} workspace={state.plugin.fileWorkspace} />}
{scumResolution?.available && <GamePlayerIntelligencePanel serverInstanceId={serverId} />}
</div>
);
}