feat(scum): add versioned gift grants

This commit is contained in:
npc0-hue
2026-07-28 16:40:35 +08:00
parent 4527175a6f
commit e2d0bc0595
26 changed files with 1224 additions and 14 deletions
+15
View File
@@ -54,6 +54,14 @@ import type {
GamePlayerStatePatchRequest,
GamePlayerStatePatchResponse,
GamePlayerStateResponse,
GameGiftCatalogListResponse,
GameGiftCatalogRequest,
GameGiftCatalogResponse,
GameGiftRevisionListResponse,
GameGiftRevisionResponse,
GameGiftGrantListResponse,
GameGiftGrantRequest,
GameGiftGrantResponse,
HealthResponse,
JobCreateRequest,
JobListResponse,
@@ -593,6 +601,13 @@ export class PlatformApiClient {
async listGamePlayerStatePatches(serverInstanceId: string, playerId: string): Promise<GamePlayerStatePatchListResponse> { return this.request<GamePlayerStatePatchListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches`); }
async requestGamePlayerStatePatch(serverInstanceId: string, playerId: string, request: GamePlayerStatePatchRequest): Promise<GamePlayerStatePatchResponse> { return this.request<GamePlayerStatePatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches`, { method: "POST", body: request }); }
async approveGamePlayerStatePatch(serverInstanceId: string, playerId: string, patchId: string): Promise<GamePlayerStatePatchResponse> { return this.request<GamePlayerStatePatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches/${encodeURIComponent(patchId)}/approve`, { method: "POST" }); }
async listGameGiftCatalogs(serverInstanceId: string): Promise<GameGiftCatalogListResponse> { return this.request<GameGiftCatalogListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`); }
async saveGameGiftCatalog(serverInstanceId: string, request: GameGiftCatalogRequest): Promise<GameGiftCatalogResponse> { return this.request<GameGiftCatalogResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`, { method: "POST", body: request }); }
async publishGameGiftCatalog(serverInstanceId: string, catalogId: string): Promise<GameGiftRevisionResponse> { return this.request<GameGiftRevisionResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/publish`, { method: "POST" }); }
async listGameGiftRevisions(serverInstanceId: string, catalogId: string): Promise<GameGiftRevisionListResponse> { return this.request<GameGiftRevisionListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/revisions`); }
async listGameGiftGrants(serverInstanceId: string): Promise<GameGiftGrantListResponse> { return this.request<GameGiftGrantListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`); }
async requestGameGiftGrant(serverInstanceId: string, request: GameGiftGrantRequest): Promise<GameGiftGrantResponse> { return this.request<GameGiftGrantResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`, { method: "POST", body: request }); }
async approveGameGiftGrant(serverInstanceId: string, grantId: string): Promise<GameGiftGrantResponse> { return this.request<GameGiftGrantResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants/${encodeURIComponent(grantId)}/approve`, { method: "POST" }); }
async getBackup(id: string): Promise<BackupResponse> {
return this.request<BackupResponse>(`/backups/${encodeURIComponent(id)}`);
+10
View File
@@ -310,6 +310,16 @@ export interface GamePlayerStatePatchChangeRequest { fieldKey: string; before: n
export interface GamePlayerStatePatchRequest { gameVersion: string; expectedStateVersion: string; safetyWindow: string; changes: GamePlayerStatePatchChangeRequest[]; reason: string; }
export interface GamePlayerStatePatchResponse { id: string; gameVersion: string; expectedStateVersion: string; changes: GamePlayerStatePatchChangeRequest[]; reason: string; requesterId: string; approverId?: string; status: "pending-approval" | "queued" | "execution-failed" | "execution-unknown" | "confirmation-failed" | "confirmed"; bridgeCommandId?: string; executionSummary?: string; confirmedStateVersion?: string; createdAt: string; approvedAt?: string; completedAt?: string; }
export interface GamePlayerStatePatchListResponse { items: GamePlayerStatePatchResponse[]; }
export interface GameGiftItemRequest { catalogItemKey: string; quantity: number; }
export interface GameGiftItemResponse extends GameGiftItemRequest { label: string; }
export interface GameGiftCatalogRequest { id?: string; name: string; gameVersion: string; items: GameGiftItemRequest[]; }
export interface GameGiftCatalogResponse { id: string; name: string; gameVersion: string; draftItems: GameGiftItemResponse[]; latestRevisionId?: string; updatedAt: string; }
export interface GameGiftCatalogListResponse { items: GameGiftCatalogResponse[]; }
export interface GameGiftRevisionResponse { id: string; catalogId: string; revision: number; gameVersion: string; items: GameGiftItemResponse[]; publishedBy: string; publishedAt: string; }
export interface GameGiftRevisionListResponse { items: GameGiftRevisionResponse[]; }
export interface GameGiftGrantRequest { revisionId: string; gamePlayerRecordId: string; notice: string; idempotencyKey: string; }
export interface GameGiftGrantResponse { id: string; revisionId: string; revisionNumber: number; gameVersion: string; items: GameGiftItemResponse[]; gamePlayerRecordId: string; playerDisplayName: string; notice: string; requesterId: string; approverId?: string; status: "pending-approval" | "queued" | "delivered" | "notification_failed" | "failed" | "unknown"; deliverySummary?: string; notificationSummary?: string; createdAt: string; approvedAt?: string; completedAt?: string; }
export interface GameGiftGrantListResponse { items: GameGiftGrantResponse[]; }
export interface RuntimeTransportProfileResponse {
key: string;
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import source from "./GameGiftCatalogPanel.tsx?raw";
describe("GameGiftCatalogPanel", () => {
it("keeps lifecycle state readable and avoids raw game command surfaces", () => {
expect(source).toContain("版本化礼包目录与定向发放");
expect(source).toContain("通知失败");
expect(source).toContain("绝不自动重试");
expect(source).not.toContain("rawCommand");
expect(source).not.toContain("sendSourceRCONCommand");
});
});
@@ -0,0 +1,25 @@
import { CheckCircle2, Gift, RefreshCw, Send } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GameGiftCatalogResponse, GameGiftGrantResponse, GameGiftRevisionResponse, GamePlayerResponse } from "../api/types";
import { ErrorState, LoadingState } from "./StateViews";
type Ready = { catalogs: GameGiftCatalogResponse[]; revisions: GameGiftRevisionResponse[]; grants: GameGiftGrantResponse[]; players: GamePlayerResponse[]; selectedCatalog?: string; selectedRevision?: string; selectedPlayer?: string; name: string; notice: string; item: "bandage" | "water-bottle" | "improvised-spear"; quantity: number; note?: string };
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; value: Ready };
const itemLabels = { bandage: "绷带", "water-bottle": "饮用水", "improvised-spear": "简易长矛" } as const;
export function GameGiftCatalogPanel({ serverInstanceId }: { serverInstanceId: string }) {
const [state, setState] = useState<State>({ status: "loading" });
const load = useCallback(async () => { setState({ status: "loading" }); try { const [catalogs, grants, players] = await Promise.all([platformApiClient.listGameGiftCatalogs(serverInstanceId), platformApiClient.listGameGiftGrants(serverInstanceId), platformApiClient.listGamePlayers(serverInstanceId)]); const selectedCatalog = catalogs.items[0]?.id; const revisions = selectedCatalog ? await platformApiClient.listGameGiftRevisions(serverInstanceId, selectedCatalog) : { items: [] }; setState({ status: "ready", value: { catalogs: catalogs.items, revisions: revisions.items, grants: grants.items, players: players.items, selectedCatalog, selectedRevision: revisions.items[0]?.id, selectedPlayer: players.items[0]?.id, name: "新手补给", notice: "已向你发放运营礼包,请查收。", item: "bandage", quantity: 2 } }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "礼包目录读取失败" }); } }, [serverInstanceId]);
useEffect(() => { void load(); }, [load]);
const edit = (patch: Partial<Ready>) => setState((current) => current.status === "ready" ? { ...current, value: { ...current.value, ...patch } } : current);
async function createDraft() { if (state.status !== "ready") return; const v = state.value; try { const saved = await platformApiClient.saveGameGiftCatalog(serverInstanceId, { id: v.selectedCatalog, name: v.name, gameVersion: "0.9.700.90357", items: [{ catalogItemKey: v.item, quantity: v.quantity }] }); edit({ catalogs: [saved, ...v.catalogs.filter((catalog) => catalog.id !== saved.id)], selectedCatalog: saved.id, revisions: [], selectedRevision: undefined, note: "草稿已保存;发布后才可定向发放。" }); } catch (error) { edit({ note: error instanceof Error ? error.message : "草稿保存失败" }); } }
async function publish() { if (state.status !== "ready" || !state.value.selectedCatalog) return; try { const revision = await platformApiClient.publishGameGiftCatalog(serverInstanceId, state.value.selectedCatalog); edit({ revisions: [revision, ...state.value.revisions], selectedRevision: revision.id, note: "已发布不可变版本,可预览并选择目标玩家。" }); } catch (error) { edit({ note: error instanceof Error ? error.message : "发布失败" }); } }
async function requestGrant() { if (state.status !== "ready") return; const v=state.value; if (!v.selectedRevision || !v.selectedPlayer) return; try { const grant=await platformApiClient.requestGameGiftGrant(serverInstanceId,{revisionId:v.selectedRevision,gamePlayerRecordId:v.selectedPlayer,notice:v.notice,idempotencyKey:`gift-${v.selectedRevision}-${v.selectedPlayer}`}); edit({grants:[grant,...v.grants],note:"发放申请已冻结礼包版本和目标身份,等待平台管理员审批。"}); } catch(error){edit({note:error instanceof Error?error.message:"发放申请失败"});} }
async function approve(grant: GameGiftGrantResponse) { if(state.status!=="ready")return;try{const next=await platformApiClient.approveGameGiftGrant(serverInstanceId,grant.id);edit({grants:state.value.grants.map((item)=>item.id===next.id?next:item),note:"已通过受控游戏通道排队发放。"});}catch(error){edit({note:error instanceof Error?error.message:"审批失败"});} }
if(state.status==="loading")return <LoadingState label="正在加载版本化 SCUM 礼包目录…"/>;
if(state.status==="error")return <ErrorState title="礼包目录不可用" reason={state.reason} onRetry={()=>void load()}/>;
const v=state.value; const selectedRevision=v.revisions.find((item)=>item.id===v.selectedRevision);
return <section className="console-panel" aria-label="SCUM 版本化礼包目录与定向发放"><div className="panel-header"><div><h2><Gift size={16}/> </h2><p className="provider-id"> SCUM RCON </p></div><button className="icon-command" type="button" onClick={()=>void load()}><RefreshCw size={14}/><span></span></button></div><div className="console-module"><h3>稿</h3><div className="console-row-list"><label className="console-row"><strong></strong><input value={v.name} maxLength={80} onChange={(event)=>edit({name:event.target.value})}/></label><label className="console-row"><strong></strong><select value={v.item} onChange={(event)=>edit({item:event.target.value as Ready["item"]})}>{Object.entries(itemLabels).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></label><label className="console-row"><strong></strong><input type="number" min={1} max={20} value={v.quantity} onChange={(event)=>edit({quantity:Number(event.target.value)})}/></label></div><div className="action-strip"><button className="command-button" type="button" onClick={()=>void createDraft()}>稿</button><select aria-label="选择礼包草稿" value={v.selectedCatalog||""} onChange={async(event)=>{const catalogId=event.target.value;const response=await platformApiClient.listGameGiftRevisions(serverInstanceId,catalogId);edit({selectedCatalog:catalogId,revisions:response.items,selectedRevision:response.items[0]?.id});}}><option value="">稿</option>{v.catalogs.map((catalog)=><option key={catalog.id} value={catalog.id}>{catalog.name} · {catalog.gameVersion}</option>)}</select><button className="primary-command" type="button" disabled={!v.selectedCatalog} onClick={()=>void publish()}></button></div></div><div className="console-module"><h3></h3><p className="page-status">{selectedRevision ? `版本 #${selectedRevision.revision}${selectedRevision.items.map((item)=>`${item.label} ×${item.quantity}`).join("、")}` : "请先发布一个礼包版本。"}</p><div className="console-row-list"><label className="console-row"><strong></strong><select value={v.selectedPlayer||""} onChange={(event)=>edit({selectedPlayer:event.target.value})}>{v.players.map((player)=><option key={player.id} value={player.id}>{player.displayName}{player.gamePlayerId}</option>)}</select></label><label className="console-row"><strong></strong><input maxLength={200} value={v.notice} onChange={(event)=>edit({notice:event.target.value})}/></label></div><button className="primary-command" type="button" disabled={!selectedRevision||!v.selectedPlayer} onClick={()=>void requestGrant()}><Send size={14}/></button></div>{v.note&&<p className="page-status">{v.note}</p>}<div className="console-record-list">{v.grants.map((grant)=><div className="console-record" key={grant.id}><strong>{grantStatus(grant.status)}</strong><span>{grant.playerDisplayName} · {grant.items.map((item)=>`${item.label} ×${item.quantity}`).join("、")}</span><small> #{grant.revisionNumber} · {grant.deliverySummary||"等待投递结果"}{grant.notificationSummary?` · 通知:${grant.notificationSummary}`:""}</small>{grant.status==="pending-approval"&&<button className="icon-command" type="button" onClick={()=>void approve(grant)}><CheckCircle2 size={14}/><span></span></button>}</div>)}</div></section>;
}
function grantStatus(status: GameGiftGrantResponse["status"]) { return ({"pending-approval":"等待平台管理员审批",queued:"已排队投递",delivered:"物品已投递,通知处理中",notification_failed:"物品已投递,定向通知失败",failed:"投递失败",unknown:"投递结果未知,绝不自动重试"})[status]; }
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GamePlayerProfileResponse, GamePlayerResponse, GamePlayerStatePatchResponse, GamePlayerStateResponse } from "../api/types";
import { ErrorState, LoadingState } from "./StateViews";
import { GameGiftCatalogPanel } from "./GameGiftCatalogPanel";
type Selected = { profile: GamePlayerProfileResponse; state?: GamePlayerStateResponse; patches: GamePlayerStatePatchResponse[]; reason: string; after: Record<string, string>; note?: string };
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; players: GamePlayerResponse[]; selected?: Selected };
@@ -18,7 +19,7 @@ export function GamePlayerIntelligencePanel({ serverInstanceId }: { serverInstan
if (state.status === "loading") return <LoadingState label="正在加载本地游戏玩家档案…" />;
if (state.status === "error") return <ErrorState title="玩家档案不可用" reason={state.reason} onRetry={() => void load()} />;
const selected = 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>{selected && <PlayerDetail selected={selected} onReason={(reason) => setSelected(setState, reason)} onAfter={(key, value) => setAfter(setState, key, value)} onSubmit={() => void submitPatch()} onApprove={(patch) => void approvePatch(patch)} />}</section>;
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>{selected && <PlayerDetail selected={selected} onReason={(reason) => setSelected(setState, reason)} onAfter={(key, value) => setAfter(setState, key, value)} onSubmit={() => void submitPatch()} onApprove={(patch) => void approvePatch(patch)} />}</section><GameGiftCatalogPanel serverInstanceId={serverInstanceId}/></>;
}
function PlayerDetail({ selected, onReason, onAfter, onSubmit, onApprove }: { selected: Selected; onReason: (value: string) => void; onAfter: (key: string, value: string) => void; onSubmit: () => void; onApprove: (patch: GamePlayerStatePatchResponse) => void }) {