feat(scum): remove legacy workflow client surfaces

This commit is contained in:
npc0-hue
2026-08-11 14:31:05 +08:00
parent a58da47c60
commit df48d8b283
22 changed files with 59 additions and 654 deletions
@@ -1,28 +0,0 @@
import type { SCUMCommandResult, SCUMConfigPatch, SCUMConfigRead, SCUMFeatureAvailability, SCUMFeatureKey, SCUMGiftGrant, SCUMPlayerProfile, SCUMStatePatch, SCUMStateSnapshot, SCUMTrajectoryCollection, SCUMVehicleSpawn } from "./contracts.js";
import { validateConfigPatch, validateStatePatch, validateVehicleSpawn } from "./schemas.js";
export type PluginFeatureBridge = { dispatch(action: "game-client.command" | "game-client.snapshot.read", payload: Record<string, string>): Promise<{ status: string; result?: Record<string, string>; error?: { message: string } }> };
export type SCUMFeatureAPI = {
availability(feature: SCUMFeatureKey): Promise<SCUMFeatureAvailability>; readConfig(): Promise<SCUMConfigRead | null>; patchConfig(patch: SCUMConfigPatch): Promise<SCUMCommandResult>;
playerProfile(playerId: string): Promise<SCUMPlayerProfile | null>; stateSnapshot(playerId: string): Promise<SCUMStateSnapshot | null>; requestStatePatch(patch: SCUMStatePatch): Promise<SCUMCommandResult>;
requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise<SCUMCommandResult>;
giftGrants(): Promise<SCUMGiftGrant[]>; trajectories(): Promise<SCUMTrajectoryCollection>;
};
export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatures: readonly SCUMFeatureAvailability[]): SCUMFeatureAPI {
const availability = async (feature: SCUMFeatureKey) => availableFeatures.find((item) => item.feature === feature) ?? { feature, available: false, reason: "插件未声明此功能。" };
return {
availability,
async readConfig() { const result = await bridge.dispatch("game-client.command", { type: "config.read" }); return result.status === "ok" ? decode<SCUMConfigRead>(result.result) : null; },
async patchConfig(patch) { const error = validateConfigPatch(patch); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "config.patch", patch: JSON.stringify(patch) })); },
async playerProfile(playerId) { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", subjectId: playerId }); return result.status === "ok" ? decode<SCUMPlayerProfile>(result.result) : null; },
async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode<SCUMStateSnapshot>(result.result) : null; },
async requestStatePatch(patch) { const error = validateStatePatch(patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); },
async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); },
async giftGrants() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "gifts" }); return result.status === "ok" ? decode<SCUMGiftGrant[]>(result.result) ?? [] : []; },
async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "trajectories" }); return result.status === "ok" ? decode<SCUMTrajectoryCollection>(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; }
};
}
function commandResult(result: { status: string; result?: Record<string, string>; error?: { message: string } }): SCUMCommandResult { if (result.status === "queued") return { status: "queued", summary: result.result?.summary ?? "已进入受控队列。" }; if (result.status === "unsupported") return { status: "unsupported", summary: result.error?.message ?? "当前运行时不支持此操作。" }; return { status: "failed", summary: result.error?.message ?? "受控操作未被接受。" }; }
function decode<T>(result: Record<string, string> | undefined): T | null { const payload = result?.payload; if (!payload) return null; try { return JSON.parse(payload) as T; } catch { return null; } }
@@ -1,14 +1,8 @@
export const scumFeatureKeys = ["configuration", "players", "rewards", "state-patches", "trajectories"] as const;
export const scumFeatureKeys = ["configuration", "players", "rewards"] as const;
export type SCUMFeatureKey = (typeof scumFeatureKeys)[number];
export type SCUMFeatureAvailability = { feature: SCUMFeatureKey; available: boolean; reason?: string };
export type SCUMMigrationProvenance = "plugin" | "transitional-read-only";
export type SCUMMigrationRecord<T = Record<string, unknown>> = { provenance: SCUMMigrationProvenance; readOnly: boolean; payload: T; recordedAt: string; sourceRecordId?: string };
export type SCUMFeatureMigrationAuthority = { serverInstanceId: string; feature: SCUMFeatureKey; authority: "plugin" | "transitional-read-only"; reason?: string };
export type SCUMFeatureMigrationStatus = { authority: "plugin" | "transitional-read-only"; readOnlyHistory: true; pluginWritesEnabled: boolean; reason?: string };
export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string; audit?: Record<string, unknown> };
export type SCUMVehicleSpawn = { vehicleCode: string };
export type SCUMVehicleSpawnOption = { code: string; label: string };
export type SCUMLogicalDirectory = { key: string; label: string; scope: "config" | "logs" };
export type SCUMLogicalFile = { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; editable?: boolean };
@@ -21,19 +15,10 @@ export type SCUMConfigPatch = { changes: Array<{ key: string; value: string }>;
export type SCUMPlayer = { id: string; gamePlayerId: string; displayName: string; lastSeenAt?: string; status: "online" | "offline" | "unknown" };
export type SCUMPlayerSession = { id: string; playerId: string; kind: "login" | "logout"; occurredAt: string; networkCorrelation?: string };
export type SCUMPlayerRisk = { kind: string; level: "low" | "medium" | "high"; observedAt: string; summary: string };
export type SCUMPlayerProfile = { player: SCUMPlayer; sessions: SCUMPlayerSession[]; risks: SCUMPlayerRisk[] };
export type SCUMPlayerProfile = { player: SCUMPlayer; sessions: SCUMPlayerSession[] };
export type SCUMGiftItem = { key: string; label: string; quantity: number };
export type SCUMGiftRevision = { id: string; catalogId: string; revision: number; items: SCUMGiftItem[]; publishedAt: string };
export type SCUMGiftGrant = { id: string; revisionId: string; playerId: string; notice: string; status: "pending-approval" | "queued" | "delivered" | "notification_failed" | "failed" | "unknown"; createdAt: string; completedAt?: string };
export type SCUMStateField = { key: string; label: string; value: number; minimum: number; maximum: number; editable: boolean; reason?: string };
export type SCUMStateSnapshot = { playerId: string; stateVersion: string; safetyWindow?: string; fields: SCUMStateField[]; observedAt: string };
export type SCUMStatePatch = { id: string; playerId: string; expectedStateVersion: string; safetyWindow: string; reason: string; changes: Array<{ fieldKey: string; before: number; after: number }>; status: "pending-approval" | "queued" | "succeeded" | "failed" | "unsupported" | "unknown"; createdAt: string };
export type SCUMTrajectoryPoint = { occurredAt: string; subjectId: string; subjectType: "player" | "vehicle"; x: number; y: number; z?: number; source: string };
export type SCUMTrajectory = { subjectId: string; subjectType: "player" | "vehicle"; points: SCUMTrajectoryPoint[]; provenance: SCUMMigrationProvenance };
export type SCUMTrajectoryCollection = { available: boolean; reason?: string; trajectories: SCUMTrajectory[] };
export type SCUMFeatureWorkspace = { defaultDirectoryKey?: string; directories?: SCUMLogicalDirectory[]; files?: SCUMLogicalFile[]; configFields?: SCUMConfigField[]; map?: { mapId: string; mapVersion: string; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number } };
@@ -1,75 +0,0 @@
import type { SCUMConfigRead, SCUMFeatureKey, SCUMFeatureMigrationAuthority, SCUMFeatureMigrationStatus, SCUMGiftGrant, SCUMMigrationRecord, SCUMPlayer, SCUMPlayerProfile, SCUMPlayerRisk, SCUMPlayerSession, SCUMStatePatch, SCUMTrajectory, SCUMTrajectoryPoint } from "./contracts.js";
import { configurationCatalog } from "./schemas.js";
export function transitionalReadOnly<T extends Record<string, unknown>>(payload: T, recordedAt: string, sourceRecordId?: string): SCUMMigrationRecord<T> { return { provenance: "transitional-read-only", readOnly: true, payload, recordedAt, sourceRecordId }; }
export function pluginOwned<T extends Record<string, unknown>>(payload: T, recordedAt: string): SCUMMigrationRecord<T> { return { provenance: "plugin", readOnly: false, payload, recordedAt }; }
// The authority flag is exact-server. Missing, duplicate, or
// transitional flags fail closed: history remains readable, but plugin writes
// are not enabled. Execution still additionally requires Companion feature
// availability; this flag never authorizes a command by itself.
export function migrationStatus(flags: readonly SCUMFeatureMigrationAuthority[], serverInstanceId: string, feature: SCUMFeatureKey): SCUMFeatureMigrationStatus {
const matches = flags.filter((flag) => flag.serverInstanceId === serverInstanceId && flag.feature === feature);
if (matches.length !== 1) return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: matches.length ? "迁移标记冲突,已保持只读。" : "当前服务器尚未启用插件权威记录。" };
const flag = matches[0];
if (flag.authority !== "plugin") return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: flag.reason ?? "过渡记录仅供只读查看。" };
return { authority: "plugin", readOnlyHistory: true, pluginWritesEnabled: true, reason: flag.reason };
}
export function migratePlayerRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMPlayer> | null {
const id = text(record.id); const gamePlayerId = text(record.gamePlayerId); const displayName = text(record.displayName); if (!id || !gamePlayerId || !displayName) return null;
const lastSeenAt = timestamp(record.lastSeenAt); const recordedAt = timestamp(record.updatedAt) ?? lastSeenAt ?? new Date(0).toISOString();
return transitionalReadOnly({ id, gamePlayerId, displayName, ...(lastSeenAt ? { lastSeenAt } : {}), status: record.online === true ? "online" : "unknown" }, recordedAt, id);
}
export function migrateConfigurationRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMConfigRead> | null {
const fields = allowlistedConfigFields(record.fields); const observedAt = timestamp(record.observedAt) ?? timestamp(record.updatedAt); if (!fields || !observedAt) return null;
return transitionalReadOnly({ fields, observedAt }, observedAt, text(record.id));
}
export function migratePlayerProfileRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMPlayerProfile> | null {
const player = migratePlayerRecord(object(record.player) ?? record); if (!player) return null;
const sessions = array(record.sessions).map((item) => migrateSession(item, player.payload.id)).filter((item): item is SCUMPlayerSession => item !== null);
const risks = [...array(record.accessAttempts), ...array(record.securitySignals)].map(migrateRisk).filter((item): item is SCUMPlayerRisk => item !== null);
const recordedAt = timestamp(record.updatedAt) ?? player.recordedAt;
return transitionalReadOnly({ player: player.payload, sessions, risks }, recordedAt, player.sourceRecordId);
}
export function migrateGiftGrantRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMGiftGrant> | null {
const id = text(record.id); const revisionId = text(record.revisionId); const playerId = text(record.gamePlayerRecordId) ?? text(record.playerId); const status = giftStatus(record.status); const createdAt = timestamp(record.createdAt); if (!id || !revisionId || !playerId || !status || !createdAt) return null;
const completedAt = timestamp(record.completedAt); const notice = optionalText(record.notice) ?? "";
return transitionalReadOnly({ id, revisionId, playerId, notice, status, createdAt, ...(completedAt ? { completedAt } : {}) }, timestamp(record.updatedAt) ?? createdAt, id);
}
export function migrateStatePatchRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMStatePatch> | null {
const id = text(record.id); const playerId = text(record.gamePlayerRecordId) ?? text(record.playerId); const expectedStateVersion = text(record.expectedStateVersion); const safetyWindow = text(record.safetyWindow); const reason = optionalText(record.reason) ?? ""; const status = stateStatus(record.status); const createdAt = timestamp(record.createdAt); const changes = array(record.changes).map(migrateStateChange).filter((item): item is { fieldKey: string; before: number; after: number } => item !== null);
if (!id || !playerId || !expectedStateVersion || !safetyWindow || !status || !createdAt || !changes.length) return null;
return transitionalReadOnly({ id, playerId, expectedStateVersion, safetyWindow, reason, changes, status, createdAt }, timestamp(record.updatedAt) ?? createdAt, id);
}
export function migrateTrajectoryRecord(record: Record<string, unknown>): SCUMTrajectory | null {
const subjectId = text(record.playerRecordId) ?? text(record.gamePlayerRecordId) ?? text(record.vehicleId) ?? text(record.entityId); const subjectType = trajectorySubjectType(record); const points = Array.isArray(record.points) && subjectId && subjectType ? record.points.map((point) => migratePoint(point, subjectId, subjectType)).filter((point): point is SCUMTrajectoryPoint => point !== null) : [];
if (!subjectId || !subjectType || !points.length) return null;
return { subjectId, subjectType, points, provenance: "transitional-read-only" };
}
export function migrateTrajectoryHistoryRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMTrajectory> | null {
const trajectory = migrateTrajectoryRecord(record); if (!trajectory) return null;
const recordedAt = timestamp(record.updatedAt) ?? trajectory.points[trajectory.points.length - 1].occurredAt;
return transitionalReadOnly(trajectory, recordedAt, text(record.id));
}
function migratePoint(value: unknown, defaultSubjectId: string, defaultSubjectType: SCUMTrajectoryPoint["subjectType"]): SCUMTrajectoryPoint | null { const record = object(value); if (!record) return null; const x = number(record.mapX) ?? number(record.worldX); const y = number(record.mapY) ?? number(record.worldY); const occurredAt = timestamp(record.recordedAt) ?? timestamp(record.occurredAt); if (x === undefined || y === undefined || !occurredAt) return null; const subjectId = text(record.playerRecordId) ?? text(record.gamePlayerRecordId) ?? text(record.vehicleId) ?? text(record.entityId) ?? defaultSubjectId; const subjectType = trajectorySubjectType(record) ?? defaultSubjectType; return { occurredAt, subjectId, subjectType, x, y, ...(number(record.worldZ) !== undefined ? { z: number(record.worldZ) } : {}), source: "transitional-read-only" }; }
function migrateSession(value: unknown, defaultPlayerId: string): SCUMPlayerSession | null { const record = object(value); const id = record && text(record.id); const playerId = record && (text(record.gamePlayerRecordId) ?? text(record.playerId) ?? defaultPlayerId); const startedAt = record && timestamp(record.startedAt); if (!id || !playerId || !startedAt) return null; const endedAt = timestamp(record.endedAt); return { id, playerId, kind: endedAt ? "logout" : "login", occurredAt: endedAt ?? startedAt }; }
function migrateRisk(value: unknown): SCUMPlayerRisk | null { const record = object(value); const observedAt = record && (timestamp(record.occurredAt) ?? timestamp(record.lastObservedAt)); const kind = record && (text(record.ruleKey) ?? text(record.outcome)); const summary = record && (text(record.summary) ?? text(record.reason)); if (!observedAt || !kind || !summary) return null; return { kind, level: "medium", observedAt, summary }; }
function migrateStateChange(value: unknown): { fieldKey: string; before: number; after: number } | null { const record = object(value); if (!record) return null; const fieldKey = text(record.fieldKey); const before = number(record.before); const after = number(record.after); return fieldKey && before !== undefined && after !== undefined ? { fieldKey, before, after } : null; }
function allowlistedConfigFields(value: unknown): Record<string, string> | null { const fields = object(value); const allowed = new Set(configurationCatalog.map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record<string, string> = {}; for (const [key, field] of Object.entries(fields)) { if (allowed.has(key) && (typeof field === "string" || typeof field === "number" || typeof field === "boolean")) result[key] = String(field); } return Object.keys(result).length ? result : null; }
function giftStatus(value: unknown): SCUMGiftGrant["status"] | null { return value === "pending-approval" || value === "queued" || value === "delivered" || value === "notification_failed" || value === "failed" || value === "unknown" ? value : null; }
function stateStatus(value: unknown): SCUMStatePatch["status"] | null { if (value === "pending-approval" || value === "queued" || value === "unsupported" || value === "unknown" || value === "execution-unknown") return value === "execution-unknown" ? "unknown" : value; if (value === "confirmed") return "succeeded"; return value === "execution-failed" || value === "confirmation-failed" || value === "failed" ? "failed" : null; }
function trajectorySubjectType(record: Record<string, unknown>): SCUMTrajectoryPoint["subjectType"] | null { if (record.kind === "player" || record.kind === "vehicle") return record.kind; return text(record.playerRecordId) || text(record.gamePlayerRecordId) ? "player" : text(record.vehicleId) ? "vehicle" : null; }
function array(value: unknown): unknown[] { return Array.isArray(value) ? value : []; }
function object(value: unknown): Record<string, unknown> | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null; }
function text(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
function optionalText(value: unknown): string | undefined { return text(value); }
function number(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; }
function timestamp(value: unknown): string | undefined { const candidate = text(value); return candidate && !Number.isNaN(Date.parse(candidate)) ? candidate : undefined; }
@@ -1,4 +1,4 @@
import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMStateField, SCUMVehicleSpawn, SCUMVehicleSpawnOption } from "./contracts.js";
import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability } from "./contracts.js";
// These are safe fallback allowlists. A Companion schema probe may narrow them
// per server, but a game version never enables or disables a feature.
@@ -9,10 +9,5 @@ export const configurationCatalog: readonly SCUMConfigField[] = [
{ key: "max-players", fileKey: "scum-server-settings", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" },
{ key: "welcome-message", fileKey: "scum-server-settings", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" }
];
export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }];
export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }];
export function supportsStateField(field: string): boolean { return stateFieldCatalog.some((candidate) => candidate.key === field); }
export function featureUnavailable(reason: string): SCUMFeatureAvailability { return { feature: "configuration", available: false, reason }; }
export function validateConfigPatch(patch: SCUMConfigPatch): string | null { if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; for (const change of patch.changes) { const field = configurationCatalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 不在受控目录中。`; if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; if ((field.control === "number" || field.control === "port") && (!Number.isInteger(Number(change.value)) || (field.minimum !== undefined && Number(change.value) < field.minimum) || (field.maximum !== undefined && Number(change.value) > field.maximum))) return `字段 ${field.label} 超出允许范围。`; } return null; }
export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { const definition = stateFieldCatalog.find((candidate) => candidate.key === field.fieldKey); if (!definition) return `字段 ${field.fieldKey} 不在运行时字段白名单中。`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after) || field.after < definition.minimum || field.after > definition.maximum) return `字段 ${definition.label} 超出允许范围。`; } return null; }
export function validateVehicleSpawn(spawn: SCUMVehicleSpawn): string | null { if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; if (!vehicleSpawnCatalog.some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在受控目录中声明。"; return null; }
-22
View File
@@ -1,22 +0,0 @@
export const scumMigrationParityFixtures = {
configuration: {
source: { id: "config-1", observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: 64, hostPath: "/srv/scum", RconPassword: "not-migrated" } },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "config-1", recordedAt: "2026-07-29T00:00:00Z", payload: { observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: "64" } } }
},
playerHistory: {
source: { updatedAt: "2026-07-29T00:10:00Z", player: { id: "player-1", gamePlayerId: "steam-1", displayName: "Mira", lastSeenAt: "2026-07-29T00:09:00Z", online: true }, sessions: [{ id: "session-1", startedAt: "2026-07-29T00:01:00Z", endedAt: "2026-07-29T00:08:00Z", networkFingerprint: "not-migrated" }], accessAttempts: [{ occurredAt: "2026-07-29T00:02:00Z", outcome: "review", reason: "manual review", networkCorrelationKey: "not-migrated" }], securitySignals: [{ lastObservedAt: "2026-07-29T00:03:00Z", ruleKey: "repeat-access", summary: "manual review", evidenceCount: 2 }] },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "player-1", recordedAt: "2026-07-29T00:10:00Z", payload: { player: { id: "player-1", gamePlayerId: "steam-1", displayName: "Mira", lastSeenAt: "2026-07-29T00:09:00Z", status: "online" }, sessions: [{ id: "session-1", playerId: "player-1", kind: "logout", occurredAt: "2026-07-29T00:08:00Z" }], risks: [{ kind: "review", level: "medium", observedAt: "2026-07-29T00:02:00Z", summary: "manual review" }, { kind: "repeat-access", level: "medium", observedAt: "2026-07-29T00:03:00Z", summary: "manual review" }] } }
},
gift: {
source: { id: "gift-1", revisionId: "revision-1", gamePlayerRecordId: "player-1", notice: "Welcome", status: "unknown", createdAt: "2026-07-29T00:20:00Z", completedAt: "2026-07-29T00:21:00Z", deliveryCommandId: "not-migrated" },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "gift-1", recordedAt: "2026-07-29T00:20:00Z", payload: { id: "gift-1", revisionId: "revision-1", playerId: "player-1", notice: "Welcome", status: "unknown", createdAt: "2026-07-29T00:20:00Z", completedAt: "2026-07-29T00:21:00Z" } }
},
statePatch: {
source: { id: "patch-1", gamePlayerRecordId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "execution-unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }], bridgeCommandId: "not-migrated" },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "patch-1", recordedAt: "2026-07-29T00:30:00Z", payload: { id: "patch-1", playerId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] } }
},
trajectory: {
source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "log-projection" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "trajectory-1", recordedAt: "2026-07-29T00:40:00Z", payload: { subjectId: "player-1", subjectType: "player", provenance: "transitional-read-only", points: [{ occurredAt: "2026-07-29T00:39:00Z", subjectId: "player-1", subjectType: "player", x: 10, y: 20, source: "transitional-read-only" }] } }
}
} as const;
+2 -38
View File
@@ -3,10 +3,8 @@ import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js";
import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js";
import { configurationCatalog, validateConfigPatch } from "../examples/scum-server-plugin/features/schemas.js";
const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8");
const projectionData = {
@@ -22,41 +20,7 @@ describe("SCUM plugin feature module", () => {
it("owns runtime allowlists without a version gate", () => {
expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message");
expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围");
expect(validateStatePatch([{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull();
expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toContain("白名单");
expect(vehicleSpawnCatalog.map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]);
expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull();
expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效");
expect(validateVehicleSpawn({ vehicleCode: "BPC_Unknown_C" })).toContain("受控目录");
});
it("maps transitional records only as read-only provenance", () => {
expect(migratePlayerRecord({ id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" })).toMatchObject({ provenance: "transitional-read-only", readOnly: true, payload: { gamePlayerId: "steam-1" } });
expect(migrateTrajectoryRecord({ playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ provenance: "transitional-read-only", points: [{ x: 10, y: 20 }] });
});
it("preserves only allowlisted transitional history for every feature area", () => {
expect(migrateConfigurationRecord({ id: "cfg-1", version: "0.9.700.90357", fields: { MaxPlayers: 64 }, observedAt: "2026-07-29T00:00:00Z", hostPath: "C:/secret" })).toMatchObject({ readOnly: true, payload: { fields: { MaxPlayers: "64" } } });
expect(migratePlayerProfileRecord({ player: { id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" }, sessions: [{ id: "s-1", gamePlayerRecordId: "p-1", startedAt: "2026-07-29T00:00:00Z", networkFingerprint: "never-copy" }], accessAttempts: [{ occurredAt: "2026-07-29T00:01:00Z", outcome: "review", reason: "manual" }] })).toMatchObject({ payload: { sessions: [{ kind: "login" }], risks: [{ summary: "manual" }] } });
expect(migrateGiftGrantRecord({ id: "gift-1", revisionId: "r-1", gamePlayerRecordId: "p-1", status: "unknown", createdAt: "2026-07-29T00:00:00Z" })).toMatchObject({ payload: { status: "unknown" }, readOnly: true });
expect(migrateStatePatchRecord({ id: "patch-1", gamePlayerRecordId: "p-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", status: "confirmed", createdAt: "2026-07-29T00:00:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] })).toMatchObject({ payload: { status: "succeeded" }, readOnly: true });
expect(migrateTrajectoryHistoryRecord({ id: "track-1", playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ sourceRecordId: "track-1", readOnly: true });
});
it("matches controlled transitional fixtures without carrying sensitive fields into plugin history", () => {
expect(migrateConfigurationRecord(scumMigrationParityFixtures.configuration.source)).toEqual(scumMigrationParityFixtures.configuration.expected);
expect(migratePlayerProfileRecord(scumMigrationParityFixtures.playerHistory.source)).toEqual(scumMigrationParityFixtures.playerHistory.expected);
expect(migrateGiftGrantRecord(scumMigrationParityFixtures.gift.source)).toEqual(scumMigrationParityFixtures.gift.expected);
expect(migrateStatePatchRecord(scumMigrationParityFixtures.statePatch.source)).toEqual(scumMigrationParityFixtures.statePatch.expected);
expect(migrateTrajectoryHistoryRecord(scumMigrationParityFixtures.trajectory.source)).toEqual(scumMigrationParityFixtures.trajectory.expected);
expect(migrateConfigurationRecord({ version: "0.9.700.90357", observedAt: "2026-07-29T00:00:00Z", fields: { hostPath: "/srv/scum" } })).toBeNull();
});
it("enables plugin authority only for one exact server-feature flag", () => {
const flags = [{ serverInstanceId: "server-1", feature: "configuration" as const, authority: "plugin" as const }];
expect(migrationStatus(flags, "server-1", "configuration")).toMatchObject({ authority: "plugin", pluginWritesEnabled: true, readOnlyHistory: true });
expect(migrationStatus(flags, "server-2", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-2", changes: [{ key: "unknown", value: "1" }] })).toContain("受控目录");
});
it("renders local user management without projection or fake-write controls", () => {