Merge remote-tracking branch 'origin/main' into feature/recruitment-command-parity-20260813
# Conflicts: # app/game-api/src/router/turns/index.ts # app/game-api/src/turns/commandInput.ts # app/game-frontend/e2e/commandArguments.spec.ts # app/game-frontend/src/components/command/ReservedCommandEditor.vue # app/game-frontend/src/components/command/types.ts # app/game-frontend/src/views/ChiefCenterView.vue
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
export * from './rng.js';
|
||||
export * from './time/Clock.js';
|
||||
export * from './time/GameClock.js';
|
||||
export * from './time/ServerDateTime.js';
|
||||
export * from './util/BytesLike.js';
|
||||
export * from './util/convertBytesLikeToArrayBuffer.js';
|
||||
export * from './util/convertBytesLikeToUint8Array.js';
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
const SERVER_UTC_OFFSET_MINUTES = 9 * 60;
|
||||
const SERVER_UTC_OFFSET_MS = SERVER_UTC_OFFSET_MINUTES * 60_000;
|
||||
|
||||
export type ServerDateTimeFormat =
|
||||
| 'dateTimeSeconds'
|
||||
| 'dateTimeMinutes'
|
||||
| 'date'
|
||||
| 'timeSeconds'
|
||||
| 'hourMinute'
|
||||
| 'minuteSecond'
|
||||
| 'monthDayTime'
|
||||
| 'monthDayTimeSeconds';
|
||||
|
||||
export type ServerDateTimeOptions = {
|
||||
format?: ServerDateTimeFormat;
|
||||
fallback?: string;
|
||||
};
|
||||
|
||||
type DateTimeParts = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
millisecond: number;
|
||||
};
|
||||
|
||||
const SERVER_WALL_TIME_PATTERN = /^(\d{4,6})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/u;
|
||||
|
||||
const pad = (value: number, length = 2): string => String(value).padStart(length, '0');
|
||||
|
||||
const isValidParts = (parts: DateTimeParts): boolean => {
|
||||
const candidate = new Date(0);
|
||||
candidate.setUTCFullYear(parts.year, parts.month - 1, parts.day);
|
||||
candidate.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
|
||||
return (
|
||||
candidate.getUTCFullYear() === parts.year &&
|
||||
candidate.getUTCMonth() + 1 === parts.month &&
|
||||
candidate.getUTCDate() === parts.day &&
|
||||
candidate.getUTCHours() === parts.hour &&
|
||||
candidate.getUTCMinutes() === parts.minute &&
|
||||
candidate.getUTCSeconds() === parts.second &&
|
||||
candidate.getUTCMilliseconds() === parts.millisecond
|
||||
);
|
||||
};
|
||||
|
||||
const parseServerWallTime = (value: string): DateTimeParts | null => {
|
||||
const match = SERVER_WALL_TIME_PATTERN.exec(value.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const millisecondText = match[7] ?? '';
|
||||
const parts: DateTimeParts = {
|
||||
year: Number(match[1]),
|
||||
month: Number(match[2]),
|
||||
day: Number(match[3]),
|
||||
hour: Number(match[4] ?? 0),
|
||||
minute: Number(match[5] ?? 0),
|
||||
second: Number(match[6] ?? 0),
|
||||
millisecond: Number(millisecondText.padEnd(3, '0')),
|
||||
};
|
||||
return isValidParts(parts) ? parts : null;
|
||||
};
|
||||
|
||||
const partsFromInstant = (value: string | Date): DateTimeParts | null => {
|
||||
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const shifted = new Date(date.getTime() + SERVER_UTC_OFFSET_MS);
|
||||
return {
|
||||
year: shifted.getUTCFullYear(),
|
||||
month: shifted.getUTCMonth() + 1,
|
||||
day: shifted.getUTCDate(),
|
||||
hour: shifted.getUTCHours(),
|
||||
minute: shifted.getUTCMinutes(),
|
||||
second: shifted.getUTCSeconds(),
|
||||
millisecond: shifted.getUTCMilliseconds(),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveParts = (value: string | Date): DateTimeParts | null => {
|
||||
if (typeof value === 'string') {
|
||||
const wallTime = parseServerWallTime(value);
|
||||
if (wallTime) {
|
||||
return wallTime;
|
||||
}
|
||||
}
|
||||
return partsFromInstant(value);
|
||||
};
|
||||
|
||||
const formatParts = (parts: DateTimeParts, format: ServerDateTimeFormat): string => {
|
||||
const year = pad(parts.year, 4);
|
||||
const month = pad(parts.month);
|
||||
const day = pad(parts.day);
|
||||
const hour = pad(parts.hour);
|
||||
const minute = pad(parts.minute);
|
||||
const second = pad(parts.second);
|
||||
|
||||
switch (format) {
|
||||
case 'dateTimeMinutes':
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
case 'date':
|
||||
return `${year}-${month}-${day}`;
|
||||
case 'timeSeconds':
|
||||
return `${hour}:${minute}:${second}`;
|
||||
case 'hourMinute':
|
||||
return `${hour}:${minute}`;
|
||||
case 'minuteSecond':
|
||||
return `${minute}:${second}`;
|
||||
case 'monthDayTime':
|
||||
return `${month}-${day} ${hour}:${minute}`;
|
||||
case 'monthDayTimeSeconds':
|
||||
return `${month}-${day} ${hour}:${minute}:${second}`;
|
||||
case 'dateTimeSeconds':
|
||||
default:
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats an instant in the service's fixed UTC+9 wall clock.
|
||||
*
|
||||
* Timezone-less legacy DATETIME strings are already server wall-clock values and
|
||||
* therefore keep their components. This deliberate fixed offset also avoids
|
||||
* historical IANA timezone rules changing ancient in-game years.
|
||||
*/
|
||||
export const formatServerDateTime = (
|
||||
value: string | Date | null | undefined,
|
||||
options: ServerDateTimeOptions = {}
|
||||
): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return options.fallback ?? '';
|
||||
}
|
||||
const parts = resolveParts(value);
|
||||
if (!parts) {
|
||||
return options.fallback ?? String(value);
|
||||
}
|
||||
return formatParts(parts, options.format ?? 'dateTimeSeconds');
|
||||
};
|
||||
|
||||
export const toServerDateTimeInputValue = (value: string | Date | null | undefined): string => {
|
||||
const formatted = formatServerDateTime(value, { format: 'dateTimeMinutes', fallback: '' });
|
||||
return formatted ? formatted.replace(' ', 'T') : '';
|
||||
};
|
||||
|
||||
/** Converts an HTML datetime-local value, interpreted as UTC+9 server wall time, to ISO UTC. */
|
||||
export const serverDateTimeInputToIso = (value: string): string | undefined => {
|
||||
const parts = parseServerWallTime(value);
|
||||
if (!parts) {
|
||||
return undefined;
|
||||
}
|
||||
const wallTime = new Date(0);
|
||||
wallTime.setUTCFullYear(parts.year, parts.month - 1, parts.day);
|
||||
wallTime.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
|
||||
return new Date(wallTime.getTime() - SERVER_UTC_OFFSET_MS).toISOString();
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatServerDateTime,
|
||||
serverDateTimeInputToIso,
|
||||
toServerDateTimeInputValue,
|
||||
} from '../src/time/ServerDateTime.js';
|
||||
|
||||
describe('formatServerDateTime', () => {
|
||||
it('formats ISO instants with the fixed UTC+9 service offset', () => {
|
||||
expect(formatServerDateTime('2026-08-13T00:05:06.000Z')).toBe('2026-08-13 09:05:06');
|
||||
expect(formatServerDateTime('0185-01-02T00:04:05.000Z')).toBe('0185-01-02 09:04:05');
|
||||
expect(formatServerDateTime('2026-08-13T18:05:06.000Z', { format: 'date' })).toBe('2026-08-14');
|
||||
expect(formatServerDateTime('2026-08-13T18:05:06.000Z', { format: 'hourMinute' })).toBe('03:05');
|
||||
});
|
||||
|
||||
it('preserves timezone-less legacy wall-clock values', () => {
|
||||
expect(formatServerDateTime('0185-01-02 03:04:05')).toBe('0185-01-02 03:04:05');
|
||||
expect(formatServerDateTime('0185-01-02T03:04:05', { format: 'monthDayTime' })).toBe('01-02 03:04');
|
||||
expect(formatServerDateTime('2026-08-13 09:05:06', { format: 'minuteSecond' })).toBe('05:06');
|
||||
});
|
||||
|
||||
it('offers explicit shapes and predictable fallbacks', () => {
|
||||
const value = '2026-08-13T00:05:06.000Z';
|
||||
expect(formatServerDateTime(value, { format: 'dateTimeMinutes' })).toBe('2026-08-13 09:05');
|
||||
expect(formatServerDateTime(value, { format: 'timeSeconds' })).toBe('09:05:06');
|
||||
expect(formatServerDateTime(value, { format: 'monthDayTimeSeconds' })).toBe('08-13 09:05:06');
|
||||
expect(formatServerDateTime(undefined, { fallback: '-' })).toBe('-');
|
||||
expect(formatServerDateTime('not-a-date')).toBe('not-a-date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('server datetime-local conversion', () => {
|
||||
it('does not depend on the browser or process timezone', () => {
|
||||
expect(serverDateTimeInputToIso('2026-08-13T09:05')).toBe('2026-08-13T00:05:00.000Z');
|
||||
expect(toServerDateTimeInputValue('2026-08-13T00:05:00.000Z')).toBe('2026-08-13T09:05');
|
||||
});
|
||||
|
||||
it('rejects invalid local input', () => {
|
||||
expect(serverDateTimeInputToIso('2026-02-30T09:05')).toBeUndefined();
|
||||
expect(serverDateTimeInputToIso('')).toBeUndefined();
|
||||
expect(toServerDateTimeInputValue('not-a-date')).toBe('');
|
||||
});
|
||||
});
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
-- Keep profile_name stable because it is referenced by operations, runtime
|
||||
-- actions, permission scopes, process names, Redis namespaces, and routes.
|
||||
-- instance_key identifies the immutable slot while current_scenario records the
|
||||
-- mutable game selection. The legacy scenario column remains during the
|
||||
-- expansion phase so the previous Gateway release can still be restored.
|
||||
ALTER TABLE "gateway_profile"
|
||||
ADD COLUMN "instance_key" TEXT,
|
||||
ADD COLUMN "current_scenario" TEXT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "gateway_profile"
|
||||
WHERE left("profile_name", length("profile") + 1) <> "profile" || ':'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'gateway_profile.profile_name must start with profile followed by a colon';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
UPDATE "gateway_profile"
|
||||
SET
|
||||
"instance_key" = substring("profile_name" FROM length("profile") + 2),
|
||||
"current_scenario" = NULLIF("scenario", 'default');
|
||||
|
||||
ALTER TABLE "gateway_profile"
|
||||
ALTER COLUMN "instance_key" SET NOT NULL;
|
||||
|
||||
DROP INDEX "gateway_profile_profile_scenario_key";
|
||||
|
||||
ALTER TABLE "gateway_profile"
|
||||
ADD CONSTRAINT "gateway_profile_profile_instance_key_key" UNIQUE ("profile", "instance_key"),
|
||||
ADD CONSTRAINT "gateway_profile_identity_check"
|
||||
CHECK (
|
||||
length("instance_key") > 0
|
||||
AND "profile_name" = "profile" || ':' || "instance_key"
|
||||
);
|
||||
|
||||
CREATE FUNCTION "sync_gateway_profile_scenario_compat"()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW."instance_key" IS NULL THEN
|
||||
IF left(NEW."profile_name", length(NEW."profile") + 1) <> NEW."profile" || ':' THEN
|
||||
RAISE EXCEPTION 'gateway_profile.profile_name must start with profile followed by a colon';
|
||||
END IF;
|
||||
NEW."instance_key" := substring(NEW."profile_name" FROM length(NEW."profile") + 2);
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW."current_scenario" IS NULL THEN
|
||||
NEW."current_scenario" := NULLIF(NEW."scenario", 'default');
|
||||
ELSE
|
||||
NEW."scenario" := NEW."current_scenario";
|
||||
END IF;
|
||||
ELSIF NEW."current_scenario" IS DISTINCT FROM OLD."current_scenario" THEN
|
||||
NEW."scenario" := COALESCE(NEW."current_scenario", 'default');
|
||||
ELSIF NEW."scenario" IS DISTINCT FROM OLD."scenario" THEN
|
||||
NEW."current_scenario" := NULLIF(NEW."scenario", 'default');
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER "gateway_profile_scenario_compat"
|
||||
BEFORE INSERT OR UPDATE ON "gateway_profile"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "sync_gateway_profile_scenario_compat"();
|
||||
@@ -208,6 +208,10 @@ model LegacyRootKeyValue {
|
||||
model GatewayProfile {
|
||||
profileName String @id @map("profile_name")
|
||||
profile String
|
||||
instanceKey String @map("instance_key")
|
||||
currentScenario String? @map("current_scenario")
|
||||
/// Legacy compatibility mirror. The database trigger keeps this synchronized
|
||||
/// with currentScenario while the previous Gateway release remains rollbackable.
|
||||
scenario String
|
||||
apiPort Int @map("api_port")
|
||||
status GatewayProfileStatus
|
||||
@@ -229,7 +233,7 @@ model GatewayProfile {
|
||||
operations GatewayOperation[]
|
||||
runtimeActions GatewayRuntimeAction[]
|
||||
|
||||
@@unique([profile, scenario])
|
||||
@@unique([profile, instanceKey])
|
||||
@@map("gateway_profile")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user