Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s

This commit is contained in:
2026-10-18 11:44:04 +05:00
parent ad73132f15
commit a7085e4e80
50 changed files with 4790 additions and 703 deletions

View File

@@ -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 }[];

View File

@@ -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[];

View File

@@ -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": "Карта"
}

View File

@@ -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
View 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");
}