183e08ec80
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
Decouple the displayed opponent name from the small pool of durable robot accounts: the disguised auto-match robot now gets a freshly composed name each game, stamped on a new game_players.display_name seat snapshot. The snapshot also captures humans' names, freezing what an opponent sees for the life of a game (a later rename no longer rewrites past games); readers fall back to the account's current name for pre-migration rows. Names come from a wide composed corpus (internal/robot/namevariety.go): Western locales (EN/DE/ES/IT/FR/PT), native Japanese/Chinese names, a gender-agreed Russian pool, and human-style handles. Routing keeps Pick's spirit -- a Russian game draws Cyrillic + <=20% Latin and never a CJK script; an English game the full corpus -- via robot.PickNamed. Loosen account.ValidateDisplayName (and the UI mirror) to admit a trailing run of up to five digits, so "Player2007"-style handles are valid for humans too and the disguised robot stays indistinguishable. Migration 00007 adds game_players.display_name (additive, NOT NULL DEFAULT ''); jet regenerated. Docs (ARCHITECTURE 7, FUNCTIONAL + _ru, PLAN, README) updated.
90 lines
3.9 KiB
TypeScript
90 lines
3.9 KiB
TypeScript
// Profile-edit validation, mirroring the backend (account/profile.go,
|
||
// account/timezone.go) so the form can disable Save and flag fields before a round
|
||
// trip. Pure and unit-tested.
|
||
|
||
/** maxDisplayName caps the editable display name in runes. */
|
||
export const maxDisplayName = 32;
|
||
|
||
/** maxDisplayNameSpecials caps the total special characters (every rune that is neither a
|
||
* letter, a space, nor a digit — i.e. the "." / "_" separators) a display name may carry.
|
||
* A trailing digit run is bounded separately by displayNameRe. Mirrors the Go rule. */
|
||
export const maxDisplayNameSpecials = 5;
|
||
|
||
/** maxAwayMinutes bounds the daily away window's length (12 h). */
|
||
export const maxAwayMinutes = 12 * 60;
|
||
|
||
// Unicode letters joined by single space / "." / "_" separators, where a "." or "_"
|
||
// may be followed by a single space. No leading separator and no adjacent separators
|
||
// except "<dot|underscore> <space>". The name may end with EITHER a single trailing "."
|
||
// (an initial) OR a run of 1–5 digits (a number or year), but not both; digits never
|
||
// appear elsewhere. Same rule as the Go displayNameRe.
|
||
const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*(?:\.|[0-9]{1,5})?$/u;
|
||
|
||
/** displayNameError returns true when the trimmed name is a valid display name. */
|
||
export function validDisplayName(raw: string): boolean {
|
||
const name = raw.trim();
|
||
const chars = [...name];
|
||
if (name.length === 0 || chars.length > maxDisplayName || !displayNameRe.test(name)) return false;
|
||
const specials = chars.filter((c) => c !== ' ' && !/\p{L}/u.test(c) && !/[0-9]/.test(c)).length;
|
||
return specials <= maxDisplayNameSpecials;
|
||
}
|
||
|
||
// A pragmatic email check (the backend re-validates with net/mail). Rejects spaces
|
||
// and requires a local part, an @, and a dotted domain.
|
||
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
|
||
/** validEmail reports whether email is a plausible address. */
|
||
export function validEmail(email: string): boolean {
|
||
return emailRe.test(email.trim());
|
||
}
|
||
|
||
/** toMinutes parses an "HH:MM" time-of-day into minutes since midnight, or null. */
|
||
export function toMinutes(hhmm: string): number | null {
|
||
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
||
if (!m) return null;
|
||
const h = Number(m[1]);
|
||
const min = Number(m[2]);
|
||
if (h > 23 || min > 59) return null;
|
||
return h * 60 + min;
|
||
}
|
||
|
||
/** awayDurationOk reports whether the away window (wrapping midnight) is <= 12 h. */
|
||
export function awayDurationOk(start: string, end: string): boolean {
|
||
const s = toMinutes(start);
|
||
const e = toMinutes(end);
|
||
if (s === null || e === null) return false;
|
||
let d = e - s;
|
||
if (d < 0) d += 24 * 60;
|
||
return d <= maxAwayMinutes;
|
||
}
|
||
|
||
/** The real-world set of unique UTC offsets, for the timezone dropdown. */
|
||
export const timezoneOffsets: string[] = [
|
||
'-12:00', '-11:00', '-10:00', '-09:30', '-09:00', '-08:00', '-07:00', '-06:00',
|
||
'-05:00', '-04:00', '-03:30', '-03:00', '-02:00', '-01:00', '+00:00', '+01:00',
|
||
'+02:00', '+03:00', '+03:30', '+04:00', '+04:30', '+05:00', '+05:30', '+05:45',
|
||
'+06:00', '+06:30', '+07:00', '+08:00', '+08:45', '+09:00', '+09:30', '+10:00',
|
||
'+10:30', '+11:00', '+12:00', '+12:45', '+13:00', '+14:00',
|
||
];
|
||
|
||
/** isOffsetZone reports whether a stored timezone is a "±HH:MM" offset. */
|
||
export function isOffsetZone(tz: string): boolean {
|
||
return /^[+-]\d{2}:\d{2}$/.test(tz);
|
||
}
|
||
|
||
/** browserOffset returns the client's current UTC offset as "±HH:MM". */
|
||
export function browserOffset(): string {
|
||
const mins = -new Date().getTimezoneOffset(); // getTimezoneOffset is minutes behind UTC
|
||
const sign = mins < 0 ? '-' : '+';
|
||
const abs = Math.abs(mins);
|
||
const hh = String(Math.floor(abs / 60)).padStart(2, '0');
|
||
const mm = String(abs % 60).padStart(2, '0');
|
||
return `${sign}${hh}:${mm}`;
|
||
}
|
||
|
||
/** Hour options "00".."23" for the away-window pickers. */
|
||
export const awayHours: string[] = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||
|
||
/** Minute options on a 10-minute step. */
|
||
export const awayMinutes: string[] = ['00', '10', '20', '30', '40', '50'];
|