Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
This commit is contained in:
@@ -62,6 +62,22 @@ export type Database = {
|
||||
};
|
||||
public: {
|
||||
Tables: {
|
||||
tournaments: {
|
||||
Row: import("./tournaments").TournamentRecord;
|
||||
Insert: Partial<import("./tournaments").TournamentRecord>;
|
||||
Update: Partial<import("./tournaments").TournamentRecord>;
|
||||
Relationships: [];
|
||||
};
|
||||
tournament_announcements: {
|
||||
Row: import("../lib/tournament-discord").TournamentAnnouncementJob;
|
||||
Insert: Partial<
|
||||
import("../lib/tournament-discord").TournamentAnnouncementJob
|
||||
>;
|
||||
Update: Partial<
|
||||
import("../lib/tournament-discord").TournamentAnnouncementJob
|
||||
>;
|
||||
Relationships: [];
|
||||
};
|
||||
map_reactions: {
|
||||
Row: {
|
||||
dataset_id: string;
|
||||
@@ -183,6 +199,7 @@ export type Database = {
|
||||
};
|
||||
multiplayer_rooms: {
|
||||
Row: {
|
||||
tournament_id: string | null;
|
||||
id: string;
|
||||
revision_id: string;
|
||||
host_player_id: string;
|
||||
@@ -195,6 +212,7 @@ export type Database = {
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
tournament_id?: string | null;
|
||||
id?: string;
|
||||
revision_id: string;
|
||||
host_player_id: string;
|
||||
@@ -216,6 +234,11 @@ export type Database = {
|
||||
};
|
||||
difficulty_presets: {
|
||||
Row: {
|
||||
pan: boolean;
|
||||
compass: boolean;
|
||||
coordinates: boolean;
|
||||
map_zoom: boolean;
|
||||
scoring: "normal" | "strict" | "gentle";
|
||||
created_at: string;
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -223,6 +246,11 @@ export type Database = {
|
||||
zoom: boolean;
|
||||
};
|
||||
Insert: {
|
||||
pan?: boolean;
|
||||
compass?: boolean;
|
||||
coordinates?: boolean;
|
||||
map_zoom?: boolean;
|
||||
scoring?: "normal" | "strict" | "gentle";
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -230,6 +258,11 @@ export type Database = {
|
||||
zoom: boolean;
|
||||
};
|
||||
Update: {
|
||||
pan?: boolean;
|
||||
compass?: boolean;
|
||||
coordinates?: boolean;
|
||||
map_zoom?: boolean;
|
||||
scoring?: "normal" | "strict" | "gentle";
|
||||
name?: string;
|
||||
seconds?: number;
|
||||
zoom?: boolean;
|
||||
@@ -818,6 +851,27 @@ export type Database = {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
create_tournament: {
|
||||
Args: { p_owner: string; p_payload: Json };
|
||||
Returns: string;
|
||||
};
|
||||
tournament_action: {
|
||||
Args: {
|
||||
p_tournament: string;
|
||||
p_account: string | null;
|
||||
p_action: string;
|
||||
p_payload?: Json;
|
||||
};
|
||||
Returns: Json;
|
||||
};
|
||||
tick_tournaments: {
|
||||
Args: Record<PropertyKey, never>;
|
||||
Returns: undefined;
|
||||
};
|
||||
claim_tournament_announcements: {
|
||||
Args: { p_worker: string; p_target?: string | null };
|
||||
Returns: import("../lib/tournament-discord").TournamentAnnouncementJob[];
|
||||
};
|
||||
map_reaction_summary: {
|
||||
Args: { p_dataset: string };
|
||||
Returns: { likes: number; dislikes: number }[];
|
||||
|
||||
@@ -14,6 +14,11 @@ export type LeaderboardEntry =
|
||||
best_score: number;
|
||||
average_score: number;
|
||||
games_played: number;
|
||||
tournament_champion?: {
|
||||
tournament_id: string;
|
||||
name: string;
|
||||
expires_at: string;
|
||||
} | null;
|
||||
};
|
||||
export type LeaderboardData = {
|
||||
entries: LeaderboardEntry[];
|
||||
|
||||
@@ -678,7 +678,7 @@
|
||||
"Create multiplayer room": "Создать комнату",
|
||||
"Retry": "Повторить",
|
||||
"Loading multiplayer room…": "Загрузка комнаты…",
|
||||
"Final standings": "Итоговая таблица",
|
||||
"Final standings": "Итоговый рейтинг",
|
||||
"This room is closed": "Эта комната закрыта",
|
||||
"An expedition with friends.": "Экспедиция с друзьями.",
|
||||
"Join your friends for five rounds on the same map.": "Присоединитесь к друзьям: пять раундов на одной карте.",
|
||||
@@ -965,5 +965,84 @@
|
||||
"Your account is active. You can use MineGeo normally.": "Ваш аккаунт активен. Вы можете пользоваться MineGeo.",
|
||||
"Account changes are disabled while access is restricted.": "Изменения аккаунта недоступны, пока доступ ограничен.",
|
||||
"View account status & appeal": "Статус аккаунта и апелляция",
|
||||
"← Account access & appeals": "← Доступ к аккаунту и апелляции"
|
||||
"← Account access & appeals": "← Доступ к аккаунту и апелляции",
|
||||
"Tournaments": "Турниры",
|
||||
"COMMUNITY TOURNAMENTS": "ТУРНИРЫ СООБЩЕСТВА",
|
||||
"One world. Up to 32 rivals.": "Один мир. До 32 соперников.",
|
||||
"Every tournament round contains five mini-rounds. Everyone plays the same locations.": "Каждый раунд турнира состоит из пяти мини-раундов. Все играют в одних и тех же локациях.",
|
||||
"Create tournament": "Создать турнир",
|
||||
"Log in to create or join": "Войдите, чтобы создать турнир или участвовать",
|
||||
"Server time": "Время сервера",
|
||||
"Tournament name": "Название турнира",
|
||||
"Saturday expedition": "Субботняя экспедиция",
|
||||
"Choose a map": "Выберите карту",
|
||||
"Player limit": "Максимум игроков",
|
||||
"Tournament rounds": "Раунды турнира",
|
||||
"Seconds per mini-round": "Секунд на мини-раунд",
|
||||
"Seconds per tournament round": "Секунд на раунд турнира",
|
||||
"Timer mode": "Режим таймера",
|
||||
"One budget for five mini-rounds": "Общее время на пять мини-раундов",
|
||||
"Separate timer for each mini-round": "Отдельный таймер на каждый мини-раунд",
|
||||
"Start time": "Время начала",
|
||||
"Announce in Discord 15 minutes before starting": "Опубликовать анонс в Discord за 15 минут до начала",
|
||||
"A shared budget starts with the first mini-round and keeps running between mini-rounds. Initial loading is excluded.": "Общий таймер запускается в первом мини-раунде и продолжает идти между мини-раундами. Первая загрузка не учитывается.",
|
||||
"The organizer joins as a player. Registration closes at the scheduled start. At least two players are needed.": "Организатор участвует как игрок. Регистрация закрывается в назначенное время. Требуются хотя бы два игрока.",
|
||||
"Creating…": "Создание…",
|
||||
"scheduled": "Запланирован",
|
||||
"finished": "Завершён",
|
||||
"cancelled": "Отменён",
|
||||
"running": "Идёт",
|
||||
"pending": "Ожидает",
|
||||
"sending": "Отправляется",
|
||||
"sent": "Отправлен",
|
||||
"skipped": "Пропущен",
|
||||
"failed": "Не отправлен",
|
||||
"Up to {count} players": "До {count} игроков",
|
||||
"{count} tournament rounds": "Раундов турнира: {count}",
|
||||
"Open tournament": "Открыть турнир",
|
||||
"The next champion could be you.": "Следующим чемпионом можешь стать ты.",
|
||||
"Create the first community tournament or check back for upcoming events.": "Создайте первый турнир сообщества или вернитесь позже за новыми событиями.",
|
||||
"Loading tournament…": "Загрузка турнира…",
|
||||
"Tournament round {round} of {total}": "Раунд турнира {round} из {total}",
|
||||
"Copy the tournament link from your address bar": "Скопируйте ссылку на турнир из адресной строки",
|
||||
"Copy invite": "Скопировать приглашение",
|
||||
"mini-rounds": "мини-раундов",
|
||||
"seconds per mini-round": "сек. на мини-раунд",
|
||||
"seconds per tournament round": "сек. на раунд турнира",
|
||||
"Discord announcement": "Анонс в Discord",
|
||||
"You’re registered. Stay on this page when the tournament starts.": "Вы зарегистрированы. Оставайтесь на этой странице к началу турнира.",
|
||||
"Leave tournament": "Покинуть турнир",
|
||||
"Tournament is full": "Все места заняты",
|
||||
"Join tournament": "Участвовать в турнире",
|
||||
"Tournament settings": "Настройки турнира",
|
||||
"Cancel tournament": "Отменить турнир",
|
||||
"Already-sent announcements are not posted again.": "Уже отправленные анонсы повторно не публикуются.",
|
||||
"Save settings": "Сохранить настройки",
|
||||
"TOURNAMENT CHAMPION": "ЧЕМПИОН ТУРНИРА",
|
||||
"The latest tournament champion holds first place on the global leaderboard for seven days.": "Победитель последнего турнира занимает первое место в глобальном рейтинге на семь дней.",
|
||||
"The latest tournament champion holds first place on the global leaderboard for seven days. Solo scores remain unchanged.": "Победитель последнего турнира занимает первое место в глобальном рейтинге на семь дней. Очки одиночных игр не меняются.",
|
||||
"Global leaderboard": "Глобальный рейтинг",
|
||||
"This tournament was cancelled. Scheduled tournaments need at least two eligible players at the start.": "Этот турнир отменён. Для старта нужны хотя бы два допущенных игрока.",
|
||||
"Tournament standings": "Рейтинг турнира",
|
||||
"Points count after each mini-round closes. Ties use total distance, then registration order.": "Очки учитываются после завершения мини-раунда. При равенстве учитываются суммарное расстояние и порядок регистрации.",
|
||||
"Champion spotlight until {date}": "Титул чемпиона до {date}",
|
||||
"View tournament standings": "Посмотреть рейтинг турнира",
|
||||
"Tournament game": "Игра турнира",
|
||||
"Allow panorama panning": "Разрешить поворот панорамы",
|
||||
"Show north indicator": "Показывать направление на север",
|
||||
"Show guess coordinates": "Показывать координаты метки",
|
||||
"Allow guess-map zoom": "Разрешить масштабирование карты",
|
||||
"Scoring strictness": "Строгость подсчёта очков",
|
||||
"Normal scoring": "Обычный подсчёт",
|
||||
"Strict scoring": "Строгий подсчёт",
|
||||
"Gentle scoring": "Мягкий подсчёт",
|
||||
"Fixed view": "Фиксированный вид",
|
||||
"Panning allowed": "Поворот разрешён",
|
||||
"Fixed panorama view": "Фиксированная панорама",
|
||||
"FIXED VIEW": "ФИКСИРОВАННЫЙ ВИД",
|
||||
"PLACE YOUR GUESS": "ПОСТАВЬТЕ МЕТКУ",
|
||||
"Minecraft map. Click to place a guess, or use arrow keys to pan and Enter to pin the center.": "Карта Minecraft. Нажмите для метки или перемещайтесь стрелками и нажмите Enter для метки в центре.",
|
||||
"Strict scoring penalizes distance more; gentle scoring gives more credit for nearby guesses. Every game still has five rounds.": "Строгий подсчёт сильнее штрафует за расстояние, мягкий даёт больше очков за близкие метки. В игре по-прежнему пять раундов.",
|
||||
"Guess selected": "Метка выбрана",
|
||||
"Map": "Карта"
|
||||
}
|
||||
|
||||
@@ -155,7 +155,9 @@ export function parseUploadManifest(value: unknown) {
|
||||
!manifest.contributionTo &&
|
||||
new Set(manifest.viewpoints.map(panoramaLocationKey)).size < 5
|
||||
)
|
||||
throw new Error("Capture at least five panoramas from different block positions");
|
||||
throw new Error(
|
||||
"Capture at least five panoramas from different block positions",
|
||||
);
|
||||
if (manifest.mediaFormat)
|
||||
throw new Error("Use map import for rendered archives");
|
||||
manifest.serverAddress =
|
||||
@@ -183,11 +185,25 @@ export const difficulties = {
|
||||
hard: { seconds: 30, zoom: false, label: "Hard" },
|
||||
} as const;
|
||||
export type Difficulty = keyof typeof difficulties;
|
||||
export const difficultyRuleDefaults = {
|
||||
pan: true,
|
||||
compass: true,
|
||||
coordinates: true,
|
||||
mapZoom: true,
|
||||
scoring: "normal" as const,
|
||||
};
|
||||
export type ScoringMode = "normal" | "strict" | "gentle";
|
||||
export type DifficultyPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
seconds: number;
|
||||
zoom: boolean;
|
||||
// Optional for sessions created before expanded rules were introduced.
|
||||
pan?: boolean;
|
||||
compass?: boolean;
|
||||
coordinates?: boolean;
|
||||
mapZoom?: boolean;
|
||||
scoring?: ScoringMode;
|
||||
};
|
||||
export const customDifficultySchema = z
|
||||
.object({
|
||||
@@ -200,9 +216,7 @@ export const customDifficultySchema = z
|
||||
)
|
||||
.refine(
|
||||
(name) =>
|
||||
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(
|
||||
name,
|
||||
),
|
||||
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(name),
|
||||
"Difficulty names cannot contain hidden controls",
|
||||
),
|
||||
seconds: z
|
||||
@@ -212,12 +226,18 @@ export const customDifficultySchema = z
|
||||
.max(600)
|
||||
.refine((seconds) => seconds % 15 === 0),
|
||||
zoom: z.boolean(),
|
||||
pan: z.boolean().default(true),
|
||||
compass: z.boolean().default(true),
|
||||
coordinates: z.boolean().default(true),
|
||||
mapZoom: z.boolean().default(true),
|
||||
scoring: z.enum(["normal", "strict", "gentle"]).default("normal"),
|
||||
})
|
||||
.strict();
|
||||
export function scoreGuess(
|
||||
a: Pick<Point, "x" | "z">,
|
||||
b: Pick<Point, "x" | "z">,
|
||||
bounds: Bounds,
|
||||
scoring: ScoringMode = "normal",
|
||||
) {
|
||||
const distance = Math.hypot(a.x - b.x, a.z - b.z);
|
||||
return {
|
||||
@@ -228,7 +248,8 @@ export function scoreGuess(
|
||||
: Math.round(
|
||||
5000 *
|
||||
Math.exp(
|
||||
(-5 * distance) /
|
||||
(-(scoring === "strict" ? 8 : scoring === "gentle" ? 3 : 5) *
|
||||
distance) /
|
||||
Math.hypot(
|
||||
bounds.maxX - bounds.minX,
|
||||
bounds.maxZ - bounds.minZ,
|
||||
|
||||
109
shared/tournaments.ts
Normal file
109
shared/tournaments.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { z } from "zod";
|
||||
import type { RoomView } from "./multiplayer";
|
||||
|
||||
const safeName = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3)
|
||||
.max(80)
|
||||
.refine(
|
||||
(value) =>
|
||||
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value),
|
||||
"Names cannot contain hidden controls",
|
||||
);
|
||||
export const tournamentSettings = z
|
||||
.object({
|
||||
name: safeName,
|
||||
datasetId: z.string().uuid(),
|
||||
difficulty: z.union([
|
||||
z.enum(["easy", "normal", "hard"]),
|
||||
z.string().uuid(),
|
||||
]),
|
||||
playerLimit: z.number().int().min(2).max(32),
|
||||
roundCount: z.number().int().min(1).max(10),
|
||||
roundTimeSeconds: z.number().int().min(15).max(3000).multipleOf(15),
|
||||
timerMode: z.enum(["shared", "perMini"]).default("shared"),
|
||||
startsAt: z.string().datetime({ offset: true }),
|
||||
announceDiscord: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) => value.timerMode !== "perMini" || value.roundTimeSeconds <= 600,
|
||||
"Mini-round timers allow at most 600 seconds",
|
||||
);
|
||||
export const tournamentEdit = z
|
||||
.object({
|
||||
startsAt: z.string().datetime({ offset: true }),
|
||||
playerLimit: z.number().int().min(2).max(32),
|
||||
announceDiscord: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
export type TournamentRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_id: string | null;
|
||||
starts_at: string;
|
||||
server_timezone: string;
|
||||
player_limit: number;
|
||||
round_count: number;
|
||||
round_time_seconds: number;
|
||||
timer_mode: "shared" | "perMini";
|
||||
status: "scheduled" | "running" | "finished" | "cancelled";
|
||||
announce_discord: boolean;
|
||||
finished_at: string | null;
|
||||
winner_id: string | null;
|
||||
};
|
||||
export type TournamentView = {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string | null;
|
||||
datasetId: string;
|
||||
startsAt: string;
|
||||
serverTimezone: string;
|
||||
playerLimit: number;
|
||||
roundCount: number;
|
||||
roundTimeSeconds: number;
|
||||
timerMode: "shared" | "perMini";
|
||||
roundDeadline?: string | null;
|
||||
announceDiscord: boolean;
|
||||
status: TournamentRecord["status"];
|
||||
currentRound: number;
|
||||
finishedAt: string | null;
|
||||
winnerId: string | null;
|
||||
you: string | null;
|
||||
roomId: string | null;
|
||||
currentRoom?: RoomView | null;
|
||||
announcementState?:
|
||||
| "pending"
|
||||
| "sending"
|
||||
| "sent"
|
||||
| "skipped"
|
||||
| "failed"
|
||||
| null;
|
||||
serverNow: number;
|
||||
players: { accountId: string; name: string; total: number; rank: number }[];
|
||||
};
|
||||
|
||||
/** Interpret a datetime-local value in the advertised server zone, never the browser zone. */
|
||||
export function serverLocalToIso(value: string, zone: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value))
|
||||
throw new Error("Choose a valid start time");
|
||||
const target = Date.parse(value + ":00Z");
|
||||
if (!Number.isFinite(target)) throw new Error("Choose a valid start time");
|
||||
const formatter = new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: zone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
let instant = target;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const shown = formatter.format(new Date(instant)).replace(" ", "T");
|
||||
if (shown === value) return new Date(instant).toISOString();
|
||||
instant += target - Date.parse(shown + ":00Z");
|
||||
}
|
||||
throw new Error("That local time does not exist in the server timezone");
|
||||
}
|
||||
Reference in New Issue
Block a user