From 565a8d1773304f56cbc2f2860929f71902565617 Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 13 Jul 2026 23:02:53 +0300 Subject: [PATCH 1/3] feat: add auto-zoom minimap to play region map setting Map authors can enable an "Auto-zoom minimap to play region" option. When set, the guess minimap zooms to the map's location bounds at the start of each round instead of the default world view. Bounds are precomputed at ingest time (min/max lat/lng over the map's locations) and delivered to the client through MatchConfig, so no answer locations are exposed and no per-round scan is required. --- apps/web/components/GuessMap.tsx | 43 ++++++++++ apps/web/components/ui/types.ts | 7 ++ .../features/home/model/derive-home-model.ts | 4 + apps/web/features/home/model/types.ts | 1 + apps/web/features/home/page/HomePageGame.tsx | 1 + .../lobby/components/MapMetadataFields.tsx | 20 +++++ .../components/maps/MapEditMetadataModal.tsx | 4 + .../web/features/maps/lib/maps-client.test.ts | 1 + apps/web/features/maps/lib/maps-client.ts | 3 + .../000054_map_auto_zoom_play_region.down.sql | 5 ++ .../000054_map_auto_zoom_play_region.up.sql | 21 +++++ pkg/contracts/contracts.go | 81 +++++++++++-------- pkg/persistence/map_ingest.go | 24 +++++- pkg/persistence/map_match_plans.go | 31 +++++++ pkg/persistence/map_scan.go | 1 + pkg/persistence/maps.go | 2 + 16 files changed, 211 insertions(+), 38 deletions(-) create mode 100644 db/migrations/000054_map_auto_zoom_play_region.down.sql create mode 100644 db/migrations/000054_map_auto_zoom_play_region.up.sql diff --git a/apps/web/components/GuessMap.tsx b/apps/web/components/GuessMap.tsx index 261c69ea..4852a87b 100644 --- a/apps/web/components/GuessMap.tsx +++ b/apps/web/components/GuessMap.tsx @@ -16,10 +16,18 @@ type RoundResult = { players: Record; }; +type PlayRegionBounds = { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; +}; + type Props = { onGuess?: (lat: number, lng: number) => void; guess?: { lat: number; lng: number }; mode?: 'guess' | 'result'; + autoZoomBounds?: PlayRegionBounds; result?: RoundResult; results?: RoundResult[]; interactiveInResult?: boolean; @@ -140,6 +148,39 @@ function FitToResult({ result }: { result: RoundResult }) { return null; } +function FitToPlayRegion({ bounds }: { bounds: PlayRegionBounds }) { + const map = useMap(); + const fittedRef = useRef(false); + + useEffect(() => { + if (fittedRef.current) return; + const referenceLng = normalizeLng((bounds.minLng + bounds.maxLng) / 2); + const corners: [number, number][] = [ + [bounds.minLat, closestWrappedLng(bounds.minLng, referenceLng)], + [bounds.maxLat, closestWrappedLng(bounds.maxLng, referenceLng)], + ]; + let cancelled = false; + const timer = window.setTimeout(() => { + if (cancelled) return; + const container = map.getContainer(); + if (!container || !container.isConnected) return; + try { + map.invalidateSize(false); + map.fitBounds(corners, { padding: [24, 24], maxZoom: 12, animate: false }); + fittedRef.current = true; + } catch { + // Ignore transient map lifecycle races during UI transitions. + } + }, 0); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [bounds, map]); + + return null; +} + function FitToResults({ results }: { results: RoundResult[] }) { const map = useMap(); const fittedKeyRef = useRef(''); @@ -493,6 +534,7 @@ export default function GuessMap({ onGuess, guess, mode = 'guess', + autoZoomBounds, result, results, interactiveInResult = false, @@ -539,6 +581,7 @@ export default function GuessMap({ /> + {mode === 'guess' && autoZoomBounds ? : null} {mode === 'guess' && onGuess ? : null} {mode === 'guess' && guess ? : null} {mode === 'result' && result ? : null} diff --git a/apps/web/components/ui/types.ts b/apps/web/components/ui/types.ts index 0b74b60e..473f9d83 100644 --- a/apps/web/components/ui/types.ts +++ b/apps/web/components/ui/types.ts @@ -148,6 +148,13 @@ export type Snapshot = { roundTimerMode?: "none" | "pressure" | "fixed"; roundTimeLimitMs?: number; pressureTimeLimitMs?: number; + autoZoomPlayRegion?: boolean; + playRegionBounds?: { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; + }; }; unranked?: boolean; state: string; diff --git a/apps/web/features/home/model/derive-home-model.ts b/apps/web/features/home/model/derive-home-model.ts index fef5044f..9617aacc 100644 --- a/apps/web/features/home/model/derive-home-model.ts +++ b/apps/web/features/home/model/derive-home-model.ts @@ -493,6 +493,10 @@ export function deriveHomeModel({ ? "No Move" : "Moving", mapName: match.snapshot?.config?.mapName || (ruleset === "nmpz" ? "A Location World" : "A Source World"), + autoZoomBounds: + matchConfig.autoZoomPlayRegion && matchConfig.playRegionBounds + ? matchConfig.playRegionBounds + : undefined, backLabel: match.sourcePartyInviteCode ? "Back to party" : "Back to lobby", diff --git a/apps/web/features/home/model/types.ts b/apps/web/features/home/model/types.ts index eed44349..999bac08 100644 --- a/apps/web/features/home/model/types.ts +++ b/apps/web/features/home/model/types.ts @@ -122,6 +122,7 @@ export type HomeGameView = { connectionIssue: string; modeName: string; mapName: string; + autoZoomBounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number }; backLabel?: "Back to lobby" | "Back to party"; streetViewInteractive: boolean; ruleset: GameRuleset; diff --git a/apps/web/features/home/page/HomePageGame.tsx b/apps/web/features/home/page/HomePageGame.tsx index 25db052c..0b361855 100644 --- a/apps/web/features/home/page/HomePageGame.tsx +++ b/apps/web/features/home/page/HomePageGame.tsx @@ -93,6 +93,7 @@ export default function HomePageGame({ onGuess={actions.placeGuess} guess={game.guess} mode="guess" + autoZoomBounds={game.autoZoomBounds} guessAvatarUrl={game.userAvatar} guessAvatarFallback={ game.participantsById[game.selfUserId]?.avatarFallback || "?" diff --git a/apps/web/features/lobby/components/MapMetadataFields.tsx b/apps/web/features/lobby/components/MapMetadataFields.tsx index 92b1acba..ca6f50d9 100644 --- a/apps/web/features/lobby/components/MapMetadataFields.tsx +++ b/apps/web/features/lobby/components/MapMetadataFields.tsx @@ -27,6 +27,8 @@ type MapMetadataFieldsProps = { setMapThumbnailCategory: (value: ThumbnailCategory) => void; mapThumbnailSearch: string; setMapThumbnailSearch: (value: string) => void; + autoZoomPlayRegion?: boolean; + setAutoZoomPlayRegion?: (value: boolean) => void; }; export function thumbnailCategoryFromKey(key: string): ThumbnailCategory { @@ -50,6 +52,8 @@ export function MapMetadataFields({ setMapThumbnailCategory, mapThumbnailSearch, setMapThumbnailSearch, + autoZoomPlayRegion, + setAutoZoomPlayRegion, }: MapMetadataFieldsProps) { const [thumbnailPickerOpen, setThumbnailPickerOpen] = useState(false); const selectedThumbnail = @@ -96,6 +100,22 @@ export function MapMetadataFields({ className={disabled ? "pointer-events-none opacity-50" : undefined} /> + + {setAutoZoomPlayRegion ? ( + + ) : null}
diff --git a/apps/web/features/lobby/components/maps/MapEditMetadataModal.tsx b/apps/web/features/lobby/components/maps/MapEditMetadataModal.tsx index 1b1ebe29..ca064f23 100644 --- a/apps/web/features/lobby/components/maps/MapEditMetadataModal.tsx +++ b/apps/web/features/lobby/components/maps/MapEditMetadataModal.tsx @@ -19,6 +19,7 @@ export function MapEditMetadataModal({ map, onClose, onSave }: MapEditMetadataMo const [mapThumbnailKey, setMapThumbnailKey] = useState(map.thumbnailKey); const [mapThumbnailCategory, setMapThumbnailCategory] = useState(thumbnailCategoryFromKey(map.thumbnailKey)); const [mapThumbnailSearch, setMapThumbnailSearch] = useState(""); + const [autoZoomPlayRegion, setAutoZoomPlayRegion] = useState(!!map.autoZoomPlayRegion); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); const saveDisabled = saving || !mapName.trim(); @@ -35,6 +36,7 @@ export function MapEditMetadataModal({ map, onClose, onSave }: MapEditMetadataMo visibility: mapVisibility, thumbnailKey: mapThumbnailKey, thumbnailVariant: map.thumbnailVariant, + autoZoomPlayRegion, }); onClose(); } catch (err) { @@ -74,6 +76,8 @@ export function MapEditMetadataModal({ map, onClose, onSave }: MapEditMetadataMo setMapThumbnailCategory={setMapThumbnailCategory} mapThumbnailSearch={mapThumbnailSearch} setMapThumbnailSearch={setMapThumbnailSearch} + autoZoomPlayRegion={autoZoomPlayRegion} + setAutoZoomPlayRegion={setAutoZoomPlayRegion} /> {error ?

{error}

: null}
diff --git a/apps/web/features/maps/lib/maps-client.test.ts b/apps/web/features/maps/lib/maps-client.test.ts index cc74dfeb..bce570c8 100644 --- a/apps/web/features/maps/lib/maps-client.test.ts +++ b/apps/web/features/maps/lib/maps-client.test.ts @@ -153,6 +153,7 @@ describe("updateMap", () => { difficulty: "hard", thumbnailKey: countryThumbnailRef, thumbnailVariant: 1, + autoZoomPlayRegion: false, }), }), ); diff --git a/apps/web/features/maps/lib/maps-client.ts b/apps/web/features/maps/lib/maps-client.ts index 3f1d47a5..ced7fa45 100644 --- a/apps/web/features/maps/lib/maps-client.ts +++ b/apps/web/features/maps/lib/maps-client.ts @@ -28,6 +28,7 @@ export type CustomMap = { trendingScore: number; favorited?: boolean; officialRegion?: string; + autoZoomPlayRegion?: boolean; rankedMoving?: boolean; rankedNmpz?: boolean; defaultMoving?: boolean; @@ -64,6 +65,7 @@ export type MapUpdateInput = { difficulty: CustomMap["difficulty"]; thumbnailKey: string; thumbnailVariant?: number; + autoZoomPlayRegion?: boolean; }; export type MapUploadQuota = { tier: "base" | "trusted" | "established"; @@ -129,6 +131,7 @@ export async function updateMap(config: RuntimeConfig, accessToken: string, mapI difficulty: input.difficulty, thumbnailKey: input.thumbnailKey, thumbnailVariant: input.thumbnailVariant || 1, + autoZoomPlayRegion: input.autoZoomPlayRegion ?? false, }), }), "Map request failed"); } diff --git a/db/migrations/000054_map_auto_zoom_play_region.down.sql b/db/migrations/000054_map_auto_zoom_play_region.down.sql new file mode 100644 index 00000000..9128d43c --- /dev/null +++ b/db/migrations/000054_map_auto_zoom_play_region.down.sql @@ -0,0 +1,5 @@ +alter table maps drop column if exists auto_zoom_play_region; +alter table maps drop column if exists bounds_min_lat_e7; +alter table maps drop column if exists bounds_max_lat_e7; +alter table maps drop column if exists bounds_min_lng_e7; +alter table maps drop column if exists bounds_max_lng_e7; diff --git a/db/migrations/000054_map_auto_zoom_play_region.up.sql b/db/migrations/000054_map_auto_zoom_play_region.up.sql new file mode 100644 index 00000000..61c87fc3 --- /dev/null +++ b/db/migrations/000054_map_auto_zoom_play_region.up.sql @@ -0,0 +1,21 @@ +alter table maps add column if not exists auto_zoom_play_region boolean not null default false; +alter table maps add column if not exists bounds_min_lat_e7 integer; +alter table maps add column if not exists bounds_max_lat_e7 integer; +alter table maps add column if not exists bounds_min_lng_e7 integer; +alter table maps add column if not exists bounds_max_lng_e7 integer; + +update maps m +set bounds_min_lat_e7 = b.min_lat, + bounds_max_lat_e7 = b.max_lat, + bounds_min_lng_e7 = b.min_lng, + bounds_max_lng_e7 = b.max_lng +from ( + select map_storage_id, + min(lat_e7) as min_lat, + max(lat_e7) as max_lat, + min(lng_e7) as min_lng, + max(lng_e7) as max_lng + from locations + group by map_storage_id +) b +where b.map_storage_id = m.storage_id; diff --git a/pkg/contracts/contracts.go b/pkg/contracts/contracts.go index db32a1db..b6a17b2b 100644 --- a/pkg/contracts/contracts.go +++ b/pkg/contracts/contracts.go @@ -107,6 +107,17 @@ type MatchConfig struct { RoundTimerMode RoundTimerMode `json:"roundTimerMode,omitempty"` RoundTimeLimitMS int64 `json:"roundTimeLimitMs,omitempty"` PressureTimeLimitMS int64 `json:"pressureTimeLimitMs,omitempty"` + AutoZoomPlayRegion bool `json:"autoZoomPlayRegion,omitempty"` + PlayRegionBounds *PlayRegionBounds `json:"playRegionBounds,omitempty"` +} + +// PlayRegionBounds is the geographic extent of a map's locations, used to +// auto-zoom the guess minimap to the play region when a round starts. +type PlayRegionBounds struct { + MinLat float64 `json:"minLat"` + MaxLat float64 `json:"maxLat"` + MinLng float64 `json:"minLng"` + MaxLng float64 `json:"maxLng"` } func NormalizeRuleset(v GameRuleset) GameRuleset { @@ -961,43 +972,45 @@ type MapPersonalBest struct { } type CustomMap struct { - ID string `json:"id"` - MapKey string `json:"mapKey"` - OwnerUserID string `json:"ownerUserId,omitempty"` - AuthorName string `json:"authorName,omitempty"` - DisplayName string `json:"displayName"` - Description string `json:"description,omitempty"` - Visibility string `json:"visibility"` - Status string `json:"status"` - Difficulty string `json:"difficulty"` - ThumbnailVariant int `json:"thumbnailVariant"` - ThumbnailKey string `json:"thumbnailKey"` - LocationCount int `json:"locationCount"` - PersonalBest *MapPersonalBest `json:"personalBest,omitempty"` - System bool `json:"system"` - Official bool `json:"official,omitempty"` - PublishedAt *time.Time `json:"publishedAt,omitempty"` - PlayCount int `json:"playCount"` - FavoriteCount int `json:"favoriteCount"` - CommentCount int `json:"commentCount"` - TrendingScore float64 `json:"trendingScore"` - Favorited bool `json:"favorited,omitempty"` - OfficialRegion string `json:"officialRegion,omitempty"` - RankedMoving bool `json:"rankedMoving,omitempty"` - RankedNMPZ bool `json:"rankedNmpz,omitempty"` - DefaultMoving bool `json:"defaultMoving,omitempty"` - DefaultNMPZ bool `json:"defaultNmpz,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + MapKey string `json:"mapKey"` + OwnerUserID string `json:"ownerUserId,omitempty"` + AuthorName string `json:"authorName,omitempty"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + Visibility string `json:"visibility"` + Status string `json:"status"` + Difficulty string `json:"difficulty"` + ThumbnailVariant int `json:"thumbnailVariant"` + ThumbnailKey string `json:"thumbnailKey"` + LocationCount int `json:"locationCount"` + PersonalBest *MapPersonalBest `json:"personalBest,omitempty"` + System bool `json:"system"` + Official bool `json:"official,omitempty"` + PublishedAt *time.Time `json:"publishedAt,omitempty"` + PlayCount int `json:"playCount"` + FavoriteCount int `json:"favoriteCount"` + CommentCount int `json:"commentCount"` + TrendingScore float64 `json:"trendingScore"` + Favorited bool `json:"favorited,omitempty"` + OfficialRegion string `json:"officialRegion,omitempty"` + AutoZoomPlayRegion bool `json:"autoZoomPlayRegion,omitempty"` + RankedMoving bool `json:"rankedMoving,omitempty"` + RankedNMPZ bool `json:"rankedNmpz,omitempty"` + DefaultMoving bool `json:"defaultMoving,omitempty"` + DefaultNMPZ bool `json:"defaultNmpz,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type CustomMapUpdate struct { - DisplayName string `json:"displayName"` - Description string `json:"description"` - Visibility string `json:"visibility"` - Difficulty string `json:"difficulty"` - ThumbnailVariant int `json:"thumbnailVariant"` - ThumbnailKey string `json:"thumbnailKey"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Visibility string `json:"visibility"` + Difficulty string `json:"difficulty"` + ThumbnailVariant int `json:"thumbnailVariant"` + ThumbnailKey string `json:"thumbnailKey"` + AutoZoomPlayRegion bool `json:"autoZoomPlayRegion"` } type MapListOptions struct { diff --git a/pkg/persistence/map_ingest.go b/pkg/persistence/map_ingest.go index c648cb6e..b587c3d6 100644 --- a/pkg/persistence/map_ingest.go +++ b/pkg/persistence/map_ingest.go @@ -126,7 +126,15 @@ func (s *pgStore) ImportOfficialMap(adminUserID string, input OfficialMapImportI `, mapID, mapStorageID); err != nil { return contracts.CustomMap{}, err } - if _, err := tx.Exec(ctx, `update maps set status='ready',location_count=$2,updated_at=now() where id=$1`, mapID, len(parsed)); err != nil { + if _, err := tx.Exec(ctx, ` + update maps set status='ready',location_count=$2, + bounds_min_lat_e7=(select min(lat_e7) from locations where map_storage_id=$3), + bounds_max_lat_e7=(select max(lat_e7) from locations where map_storage_id=$3), + bounds_min_lng_e7=(select min(lng_e7) from locations where map_storage_id=$3), + bounds_max_lng_e7=(select max(lng_e7) from locations where map_storage_id=$3), + updated_at=now() + where id=$1 + `, mapID, len(parsed), mapStorageID); err != nil { return contracts.CustomMap{}, err } if _, err := tx.Exec(ctx, `insert into map_aliases(alias,map_id) values($1,$2) on conflict(alias) do update set map_id=excluded.map_id`, mapKey, mapID); err != nil { @@ -240,7 +248,15 @@ func (s *pgStore) ingestCustomMap(userID, mapID, displayName, description, visib if err != nil { return contracts.CustomMap{}, err } - if _, err := tx.Exec(ctx, `update maps set status='ready',location_count=$2,content_hash=$3,rejected_location_count=$4,updated_at=now() where id=$1`, mapID, len(parsed), digestBytes, rejected); err != nil { + if _, err := tx.Exec(ctx, ` + update maps set status='ready',location_count=$2,content_hash=$3,rejected_location_count=$4, + bounds_min_lat_e7=(select min(lat_e7) from locations where map_storage_id=$5), + bounds_max_lat_e7=(select max(lat_e7) from locations where map_storage_id=$5), + bounds_min_lng_e7=(select min(lng_e7) from locations where map_storage_id=$5), + bounds_max_lng_e7=(select max(lng_e7) from locations where map_storage_id=$5), + updated_at=now() + where id=$1 + `, mapID, len(parsed), digestBytes, rejected, mapStorageID); err != nil { return contracts.CustomMap{}, err } if _, err := tx.Exec(ctx, `insert into map_aliases(alias,map_id) select map_key,id from maps where id=$1 on conflict(alias) do update set map_id=excluded.map_id`, mapID); err != nil { @@ -273,9 +289,9 @@ func (s *pgStore) UpdateCustomMap(userID, mapID string, update contracts.CustomM mapID = canonicalID tag, err := s.pool.Exec(ctx, ` update maps - set display_name=$3, description=$4, visibility=$5, difficulty=$6, thumbnail_variant=$7, thumbnail_key=$8, updated_at=now() + set display_name=$3, description=$4, visibility=$5, difficulty=$6, thumbnail_variant=$7, thumbnail_key=$8, auto_zoom_play_region=$9, updated_at=now() where id=$1 and owner_user_id=$2 and archived_at is null - `, strings.TrimSpace(mapID), strings.TrimSpace(userID), name, strings.TrimSpace(update.Description), normalizeMapVisibility(update.Visibility), normalizeMapDifficulty(update.Difficulty), normalizeThumbnailVariant(update.ThumbnailVariant), normalizeThumbnailKey(update.ThumbnailKey, update.ThumbnailVariant)) + `, strings.TrimSpace(mapID), strings.TrimSpace(userID), name, strings.TrimSpace(update.Description), normalizeMapVisibility(update.Visibility), normalizeMapDifficulty(update.Difficulty), normalizeThumbnailVariant(update.ThumbnailVariant), normalizeThumbnailKey(update.ThumbnailKey, update.ThumbnailVariant), update.AutoZoomPlayRegion) if err != nil { return contracts.CustomMap{}, err } diff --git a/pkg/persistence/map_match_plans.go b/pkg/persistence/map_match_plans.go index cde0ef76..1a27826b 100644 --- a/pkg/persistence/map_match_plans.go +++ b/pkg/persistence/map_match_plans.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "geoduels/pkg/contracts" ) @@ -44,6 +45,7 @@ func (s *pgStore) PrepareMatchPlan(ctx context.Context, found *contracts.MatchFo _ = tx.QueryRow(ctx, `select display_name from maps where id=$1`, found.ResolvedMap.MapID).Scan(&found.ResolvedMap.DisplayName) found.Config.MapID = found.ResolvedMap.MapID found.Config.MapName = found.ResolvedMap.DisplayName + applyPlayRegionConfig(ctx, tx, found.ResolvedMap.MapID, &found.Config) return tx.Commit(ctx) } cfg := contracts.NormalizeMatchConfig(found.Config) @@ -97,12 +99,41 @@ func (s *pgStore) PrepareMatchPlan(ctx context.Context, found *contracts.MatchFo found.Config.MapID = mapID found.Config.MapName = displayName found.Config.MapKey = "" + applyPlayRegionConfig(ctx, tx, mapID, &found.Config) if err := incrementMapPlayStats(ctx, tx, mapID, found.Players); err != nil { return err } return tx.Commit(ctx) } +// applyPlayRegionConfig sets the auto-zoom flag and precomputed play-region +// bounds on the match config so the client can zoom the minimap when the map +// author opted in. Missing bounds simply leave the config untouched. +func applyPlayRegionConfig(ctx context.Context, tx pgx.Tx, mapID string, cfg *contracts.MatchConfig) { + if cfg == nil || strings.TrimSpace(mapID) == "" { + return + } + var autoZoom bool + var minLat, maxLat, minLng, maxLng pgtype.Int4 + if err := tx.QueryRow(ctx, `select auto_zoom_play_region,bounds_min_lat_e7,bounds_max_lat_e7,bounds_min_lng_e7,bounds_max_lng_e7 from maps where id=$1`, mapID). + Scan(&autoZoom, &minLat, &maxLat, &minLng, &maxLng); err != nil { + return + } + if !autoZoom { + return + } + if !(minLat.Valid && maxLat.Valid && minLng.Valid && maxLng.Valid) { + return + } + cfg.AutoZoomPlayRegion = true + cfg.PlayRegionBounds = &contracts.PlayRegionBounds{ + MinLat: float64(minLat.Int32) / 1e7, + MaxLat: float64(maxLat.Int32) / 1e7, + MinLng: float64(minLng.Int32) / 1e7, + MaxLng: float64(maxLng.Int32) / 1e7, + } +} + func selectedMapAccessible(ownerUserID, accessUserID, visibility string) bool { if ownerUserID == "" || ownerUserID == accessUserID { return true diff --git a/pkg/persistence/map_scan.go b/pkg/persistence/map_scan.go index 0de28180..e476c499 100644 --- a/pkg/persistence/map_scan.go +++ b/pkg/persistence/map_scan.go @@ -41,6 +41,7 @@ func scanCustomMap(row customMapScanner) (contracts.CustomMap, error) { &item.TrendingScore, &item.Favorited, &item.OfficialRegion, + &item.AutoZoomPlayRegion, &item.RankedMoving, &item.RankedNMPZ, &item.DefaultMoving, diff --git a/pkg/persistence/maps.go b/pkg/persistence/maps.go index f99d3261..59b37cfd 100644 --- a/pkg/persistence/maps.go +++ b/pkg/persistence/maps.go @@ -66,6 +66,7 @@ func (s *pgStore) ListMaps(userID string, opts contracts.MapListOptions) ([]cont coalesce(m.published_at, '0001-01-01'::timestamptz), m.play_count, m.favorite_count, m.comment_count, m.trending_score, exists(select 1 from map_favorites mf where mf.map_id=m.id and mf.user_id=nullif($1,'')::uuid), trim(both ':' from concat_ws(':', nullif(m.official_region_type,''), nullif(m.official_region_code,''))), + m.auto_zoom_play_region, coalesce((select value_json->>'rankedMovingMapId' from site_settings where key='gameplay_map_settings'), '') in (m.id::text,m.map_key), coalesce((select value_json->>'rankedNmpzMapId' from site_settings where key='gameplay_map_settings'), '') in (m.id::text,m.map_key), coalesce((select value_json->>'singleplayerMovingMapId' from site_settings where key='gameplay_map_settings'), '') in (m.id::text,m.map_key), @@ -135,6 +136,7 @@ func (s *pgStore) GetMap(userID, mapID string) (contracts.MapDetails, bool, erro coalesce(m.published_at, '0001-01-01'::timestamptz), m.play_count, m.favorite_count, m.comment_count, m.trending_score, exists(select 1 from map_favorites mf where mf.map_id=m.id and mf.user_id=nullif($2,'')::uuid), trim(both ':' from concat_ws(':', nullif(m.official_region_type,''), nullif(m.official_region_code,''))), + m.auto_zoom_play_region, coalesce((select value_json->>'rankedMovingMapId' from site_settings where key='gameplay_map_settings'), '') in (m.id::text,m.map_key), coalesce((select value_json->>'rankedNmpzMapId' from site_settings where key='gameplay_map_settings'), '') in (m.id::text,m.map_key), coalesce((select value_json->>'singleplayerMovingMapId' from site_settings where key='gameplay_map_settings'), '') in (m.id::text,m.map_key), From 10027eab4ebc91beb42bcde6ca33f35b735f15be Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 13 Jul 2026 23:08:08 +0300 Subject: [PATCH 2/3] test: cover auto-zoom play-region bounds derivation Add unit coverage for the play-region bounds helper (e7-to-degree conversion and the disabled/missing-bound guards), source assertions that ingest computes bounds and the match plan applies them, and frontend tests for deriving autoZoomBounds from the match config. --- .../home/model/derive-home-model.test.ts | 43 +++++++++++++ pkg/persistence/map_match_plans.go | 17 +++-- pkg/persistence/map_match_plans_test.go | 64 ++++++++++++++++++- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/apps/web/features/home/model/derive-home-model.test.ts b/apps/web/features/home/model/derive-home-model.test.ts index 46567af6..0d8b91a5 100644 --- a/apps/web/features/home/model/derive-home-model.test.ts +++ b/apps/web/features/home/model/derive-home-model.test.ts @@ -143,6 +143,49 @@ describe('deriveHomeModel', () => { expect(model.meta.appVersion).toBe('dev'); }); + it('exposes play-region bounds only when auto-zoom is enabled with bounds', () => { + const bounds = { minLat: 10, maxLat: 20, minLng: 30, maxLng: 40 }; + const model = deriveHomeModel({ + auth: createAuthState(), + match: createMatchState(createSnapshot({ + config: { autoZoomPlayRegion: true, playRegionBounds: bounds } + })), + game: createGameState(), + config, + routeMatchId: 'match-1' + }); + + expect(model.game.autoZoomBounds).toEqual(bounds); + }); + + it('omits play-region bounds when auto-zoom is disabled', () => { + const model = deriveHomeModel({ + auth: createAuthState(), + match: createMatchState(createSnapshot({ + config: { autoZoomPlayRegion: false, playRegionBounds: { minLat: 10, maxLat: 20, minLng: 30, maxLng: 40 } } + })), + game: createGameState(), + config, + routeMatchId: 'match-1' + }); + + expect(model.game.autoZoomBounds).toBeUndefined(); + }); + + it('omits play-region bounds when the config provides no bounds', () => { + const model = deriveHomeModel({ + auth: createAuthState(), + match: createMatchState(createSnapshot({ + config: { autoZoomPlayRegion: true } + })), + game: createGameState(), + config, + routeMatchId: 'match-1' + }); + + expect(model.game.autoZoomBounds).toBeUndefined(); + }); + it('uses a question mark avatar fallback for users without a linked account', () => { const snapshot = createSnapshot({ players: { diff --git a/pkg/persistence/map_match_plans.go b/pkg/persistence/map_match_plans.go index 1a27826b..d701a717 100644 --- a/pkg/persistence/map_match_plans.go +++ b/pkg/persistence/map_match_plans.go @@ -119,14 +119,23 @@ func applyPlayRegionConfig(ctx context.Context, tx pgx.Tx, mapID string, cfg *co Scan(&autoZoom, &minLat, &maxLat, &minLng, &maxLng); err != nil { return } + if bounds := buildPlayRegionBounds(autoZoom, minLat, maxLat, minLng, maxLng); bounds != nil { + cfg.AutoZoomPlayRegion = true + cfg.PlayRegionBounds = bounds + } +} + +// buildPlayRegionBounds converts the stored e7 map bounds into a client-facing +// PlayRegionBounds. It returns nil when auto-zoom is disabled or any bound is +// missing, so the caller leaves the match config untouched in those cases. +func buildPlayRegionBounds(autoZoom bool, minLat, maxLat, minLng, maxLng pgtype.Int4) *contracts.PlayRegionBounds { if !autoZoom { - return + return nil } if !(minLat.Valid && maxLat.Valid && minLng.Valid && maxLng.Valid) { - return + return nil } - cfg.AutoZoomPlayRegion = true - cfg.PlayRegionBounds = &contracts.PlayRegionBounds{ + return &contracts.PlayRegionBounds{ MinLat: float64(minLat.Int32) / 1e7, MaxLat: float64(maxLat.Int32) / 1e7, MinLng: float64(minLng.Int32) / 1e7, diff --git a/pkg/persistence/map_match_plans_test.go b/pkg/persistence/map_match_plans_test.go index f90b0e66..51005f4a 100644 --- a/pkg/persistence/map_match_plans_test.go +++ b/pkg/persistence/map_match_plans_test.go @@ -1,6 +1,68 @@ package persistence -import "testing" +import ( + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgtype" +) + +func int4(v int32) pgtype.Int4 { + return pgtype.Int4{Int32: v, Valid: true} +} + +func TestBuildPlayRegionBounds(t *testing.T) { + t.Run("disabled returns nil", func(t *testing.T) { + if got := buildPlayRegionBounds(false, int4(1), int4(2), int4(3), int4(4)); got != nil { + t.Fatalf("expected nil bounds when auto-zoom disabled, got %#v", got) + } + }) + + t.Run("missing bound returns nil", func(t *testing.T) { + if got := buildPlayRegionBounds(true, int4(1), pgtype.Int4{}, int4(3), int4(4)); got != nil { + t.Fatalf("expected nil bounds when a bound is missing, got %#v", got) + } + }) + + t.Run("converts e7 to degrees", func(t *testing.T) { + got := buildPlayRegionBounds(true, int4(-123456789), int4(234567890), int4(-1_000_000_000), int4(1_800_000_000)) + if got == nil { + t.Fatal("expected bounds, got nil") + } + if got.MinLat != -12.3456789 || got.MaxLat != 23.456789 { + t.Fatalf("unexpected latitude bounds: %#v", got) + } + if got.MinLng != -100 || got.MaxLng != 180 { + t.Fatalf("unexpected longitude bounds: %#v", got) + } + }) +} + +func TestPrepareMatchPlanAppliesPlayRegionConfig(t *testing.T) { + body, err := os.ReadFile("map_match_plans.go") + if err != nil { + t.Fatal(err) + } + source := string(body) + if got := strings.Count(source, "applyPlayRegionConfig(ctx, tx,"); got != 2 { + t.Fatalf("applyPlayRegionConfig calls = %d, want 2 (reload and fresh plan paths)", got) + } +} + +func TestMapIngestComputesPlayRegionBounds(t *testing.T) { + body, err := os.ReadFile("map_ingest.go") + if err != nil { + t.Fatal(err) + } + source := string(body) + if got := strings.Count(source, "bounds_min_lat_e7=(select min(lat_e7)"); got != 2 { + t.Fatalf("bounds computation on ingest = %d, want 2 (create/replace and official import)", got) + } + if !strings.Contains(source, "auto_zoom_play_region=$9") { + t.Fatal("UpdateCustomMap must persist auto_zoom_play_region") + } +} func TestSelectedMapAccessible(t *testing.T) { tests := []struct { From 75ba20d567c244e622b783f56286c24e58f677ed Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 13 Jul 2026 23:46:21 +0300 Subject: [PATCH 3/3] fix: coderabbit reccomendations --- apps/web/components/GuessMap.tsx | 16 ++--- apps/web/components/guess-map-bounds.test.ts | 19 +++++ apps/web/components/guess-map-bounds.ts | 22 ++++++ .../web/features/maps/lib/maps-client.test.ts | 34 +++++++++ apps/web/features/maps/lib/maps-client.ts | 4 +- .../000054_map_auto_zoom_play_region.up.sql | 42 ++++++++--- pkg/persistence/map_ingest.go | 71 ++++++++++++++++--- pkg/persistence/map_match_plans_test.go | 44 +++++++++++- 8 files changed, 219 insertions(+), 33 deletions(-) create mode 100644 apps/web/components/guess-map-bounds.test.ts create mode 100644 apps/web/components/guess-map-bounds.ts diff --git a/apps/web/components/GuessMap.tsx b/apps/web/components/GuessMap.tsx index 4852a87b..0d249078 100644 --- a/apps/web/components/GuessMap.tsx +++ b/apps/web/components/GuessMap.tsx @@ -1,6 +1,7 @@ import { MapContainer, Marker, Polyline, TileLayer, useMap, useMapEvents } from 'react-leaflet'; import { useEffect, useMemo, useRef, useState } from 'react'; import L from 'leaflet'; +import { playRegionCorners, type PlayRegionBounds } from './guess-map-bounds'; type RoundPlayerResult = { userId: string; @@ -16,13 +17,6 @@ type RoundResult = { players: Record; }; -type PlayRegionBounds = { - minLat: number; - maxLat: number; - minLng: number; - maxLng: number; -}; - type Props = { onGuess?: (lat: number, lng: number) => void; guess?: { lat: number; lng: number }; @@ -148,17 +142,15 @@ function FitToResult({ result }: { result: RoundResult }) { return null; } +// FitToPlayRegion zooms the guess minimap to the map's precomputed play-region +// bounds once, when the map author enabled auto-zoom for the current round. function FitToPlayRegion({ bounds }: { bounds: PlayRegionBounds }) { const map = useMap(); const fittedRef = useRef(false); useEffect(() => { if (fittedRef.current) return; - const referenceLng = normalizeLng((bounds.minLng + bounds.maxLng) / 2); - const corners: [number, number][] = [ - [bounds.minLat, closestWrappedLng(bounds.minLng, referenceLng)], - [bounds.maxLat, closestWrappedLng(bounds.maxLng, referenceLng)], - ]; + const corners = playRegionCorners(bounds); let cancelled = false; const timer = window.setTimeout(() => { if (cancelled) return; diff --git a/apps/web/components/guess-map-bounds.test.ts b/apps/web/components/guess-map-bounds.test.ts new file mode 100644 index 00000000..641abfbd --- /dev/null +++ b/apps/web/components/guess-map-bounds.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { playRegionCorners } from './guess-map-bounds'; + +describe('playRegionCorners', () => { + it('passes through a non-crossing region unchanged', () => { + expect(playRegionCorners({ minLat: 10, maxLat: 20, minLng: 30, maxLng: 40 })).toEqual([ + [10, 30], + [20, 40], + ]); + }); + + it('unwraps the eastern edge for an antimeridian-crossing region', () => { + // Region runs east from +177 to -178, i.e. a narrow 5-degree band. + expect(playRegionCorners({ minLat: -18, maxLat: -17, minLng: 177, maxLng: -178 })).toEqual([ + [-18, 177], + [-17, 182], + ]); + }); +}); diff --git a/apps/web/components/guess-map-bounds.ts b/apps/web/components/guess-map-bounds.ts new file mode 100644 index 00000000..93bf94f4 --- /dev/null +++ b/apps/web/components/guess-map-bounds.ts @@ -0,0 +1,22 @@ +export type PlayRegionBounds = { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; +}; + +/** + * Converts stored play-region bounds into Leaflet corners. + * + * Longitude bounds follow the shortest-circular-interval convention: the region + * runs eastward from `minLng` to `maxLng`, and `maxLng < minLng` signals that it + * crosses the antimeridian. In that case the eastern edge is unwrapped past + * +180 so Leaflet frames the narrow region instead of the whole globe. + */ +export function playRegionCorners(bounds: PlayRegionBounds): [[number, number], [number, number]] { + const east = bounds.maxLng < bounds.minLng ? bounds.maxLng + 360 : bounds.maxLng; + return [ + [bounds.minLat, bounds.minLng], + [bounds.maxLat, east], + ]; +} diff --git a/apps/web/features/maps/lib/maps-client.test.ts b/apps/web/features/maps/lib/maps-client.test.ts index bce570c8..235b1ef9 100644 --- a/apps/web/features/maps/lib/maps-client.test.ts +++ b/apps/web/features/maps/lib/maps-client.test.ts @@ -140,6 +140,7 @@ describe("updateMap", () => { difficulty: "hard", thumbnailKey: countryThumbnailRef, thumbnailVariant: 1, + autoZoomPlayRegion: false, })).resolves.toEqual(map); expect(fetchMock).toHaveBeenCalledWith( @@ -158,6 +159,39 @@ describe("updateMap", () => { }), ); }); + + it("sends autoZoomPlayRegion true when enabled", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({}), + } as unknown as Response); + + await updateMap(config, "token", "map-1", { + displayName: "Better Corners", + description: "Updated route notes", + visibility: "public", + difficulty: "hard", + thumbnailKey: countryThumbnailRef, + thumbnailVariant: 1, + autoZoomPlayRegion: true, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/v1/maps/map-1", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + displayName: "Better Corners", + description: "Updated route notes", + visibility: "public", + difficulty: "hard", + thumbnailKey: countryThumbnailRef, + thumbnailVariant: 1, + autoZoomPlayRegion: true, + }), + }), + ); + }); }); describe("setMapCommentLike", () => { diff --git a/apps/web/features/maps/lib/maps-client.ts b/apps/web/features/maps/lib/maps-client.ts index ced7fa45..5cdc8258 100644 --- a/apps/web/features/maps/lib/maps-client.ts +++ b/apps/web/features/maps/lib/maps-client.ts @@ -65,7 +65,7 @@ export type MapUpdateInput = { difficulty: CustomMap["difficulty"]; thumbnailKey: string; thumbnailVariant?: number; - autoZoomPlayRegion?: boolean; + autoZoomPlayRegion: boolean; }; export type MapUploadQuota = { tier: "base" | "trusted" | "established"; @@ -131,7 +131,7 @@ export async function updateMap(config: RuntimeConfig, accessToken: string, mapI difficulty: input.difficulty, thumbnailKey: input.thumbnailKey, thumbnailVariant: input.thumbnailVariant || 1, - autoZoomPlayRegion: input.autoZoomPlayRegion ?? false, + autoZoomPlayRegion: input.autoZoomPlayRegion, }), }), "Map request failed"); } diff --git a/db/migrations/000054_map_auto_zoom_play_region.up.sql b/db/migrations/000054_map_auto_zoom_play_region.up.sql index 61c87fc3..bc09b28c 100644 --- a/db/migrations/000054_map_auto_zoom_play_region.up.sql +++ b/db/migrations/000054_map_auto_zoom_play_region.up.sql @@ -4,18 +4,44 @@ alter table maps add column if not exists bounds_max_lat_e7 integer; alter table maps add column if not exists bounds_min_lng_e7 integer; alter table maps add column if not exists bounds_max_lng_e7 integer; +-- Latitude bounds are a plain min/max. update maps m set bounds_min_lat_e7 = b.min_lat, - bounds_max_lat_e7 = b.max_lat, - bounds_min_lng_e7 = b.min_lng, - bounds_max_lng_e7 = b.max_lng + bounds_max_lat_e7 = b.max_lat from ( - select map_storage_id, - min(lat_e7) as min_lat, - max(lat_e7) as max_lat, - min(lng_e7) as min_lng, - max(lng_e7) as max_lng + select map_storage_id, min(lat_e7) as min_lat, max(lat_e7) as max_lat from locations group by map_storage_id ) b where b.map_storage_id = m.storage_id; + +-- Longitude bounds use the shortest circular interval (the complement of the +-- largest empty gap between adjacent longitudes) so antimeridian-crossing maps +-- produce a narrow region. Stored as [min_lng_e7, max_lng_e7] traversed east; +-- min_lng_e7 > max_lng_e7 when the interval crosses the antimeridian. +with ordered as ( + select map_storage_id, lng_e7, + lead(lng_e7) over (partition by map_storage_id order by lng_e7) as next_lng, + min(lng_e7) over (partition by map_storage_id) as min_lng, + max(lng_e7) over (partition by map_storage_id) as max_lng + from (select distinct map_storage_id, lng_e7 from locations) d +), +gaps as ( + select map_storage_id, (next_lng::bigint - lng_e7) as gap, next_lng as start_lng, lng_e7 as end_lng + from ordered + where next_lng is not null + union all + select map_storage_id, (min_lng::bigint + 3600000000 - max_lng) as gap, min_lng as start_lng, max_lng as end_lng + from ordered + where next_lng is null +), +best as ( + select distinct on (map_storage_id) map_storage_id, start_lng, end_lng + from gaps + order by map_storage_id, gap desc, start_lng +) +update maps m +set bounds_min_lng_e7 = b.start_lng, + bounds_max_lng_e7 = b.end_lng +from best b +where b.map_storage_id = m.storage_id; diff --git a/pkg/persistence/map_ingest.go b/pkg/persistence/map_ingest.go index b587c3d6..882f707b 100644 --- a/pkg/persistence/map_ingest.go +++ b/pkg/persistence/map_ingest.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "sort" "strings" "time" @@ -15,6 +16,60 @@ import ( "geoduels/pkg/entityid" ) +// fullCircleE7 is 360 degrees expressed in the e7 fixed-point scale. +const fullCircleE7 = int64(3_600_000_000) + +// computePlayRegionBoundsE7 derives the play-region bounding box for a set of +// parsed map locations. Latitude uses a plain min/max; longitude uses the +// shortest circular interval so that antimeridian-crossing maps stay narrow. +func computePlayRegionBoundsE7(rows []mapRow) (minLat, maxLat, minLng, maxLng int32) { + minLat, maxLat = rows[0].LatE7, rows[0].LatE7 + lngs := make([]int32, 0, len(rows)) + for _, row := range rows { + if row.LatE7 < minLat { + minLat = row.LatE7 + } + if row.LatE7 > maxLat { + maxLat = row.LatE7 + } + lngs = append(lngs, row.LngE7) + } + minLng, maxLng = wrappedLngBoundsE7(lngs) + return +} + +// wrappedLngBoundsE7 returns the shortest circular longitude interval covering +// all points, expressed as [start, end] traversed eastward. The interval is the +// complement of the largest empty gap between adjacent longitudes. When it +// crosses the antimeridian, start is numerically greater than end. +func wrappedLngBoundsE7(lngs []int32) (start, end int32) { + sorted := append([]int32(nil), lngs...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + uniq := sorted[:0] + for i, v := range sorted { + if i == 0 || v != uniq[len(uniq)-1] { + uniq = append(uniq, v) + } + } + if len(uniq) == 1 { + return uniq[0], uniq[0] + } + // Default to the non-crossing interval min..max, whose empty gap is the + // wrap-around segment across the antimeridian. + start, end = uniq[0], uniq[len(uniq)-1] + largestGap := (int64(uniq[0]) + fullCircleE7) - int64(uniq[len(uniq)-1]) + for i := 0; i < len(uniq)-1; i++ { + gap := int64(uniq[i+1]) - int64(uniq[i]) + if gap > largestGap { + largestGap = gap + // The empty region is (uniq[i], uniq[i+1]); the populated region + // wraps from uniq[i+1] eastward back to uniq[i]. + start, end = uniq[i+1], uniq[i] + } + } + return start, end +} + func (s *pgStore) CreateCustomMap(userID, displayName, description, visibility, difficulty, thumbnailKey string, thumbnailVariant int, source io.Reader) (contracts.CustomMap, error) { mapID := entityid.New() return s.ingestCustomMap(userID, mapID, displayName, description, visibility, difficulty, thumbnailKey, thumbnailVariant, source, true) @@ -126,15 +181,13 @@ func (s *pgStore) ImportOfficialMap(adminUserID string, input OfficialMapImportI `, mapID, mapStorageID); err != nil { return contracts.CustomMap{}, err } + boundsMinLat, boundsMaxLat, boundsMinLng, boundsMaxLng := computePlayRegionBoundsE7(parsed) if _, err := tx.Exec(ctx, ` update maps set status='ready',location_count=$2, - bounds_min_lat_e7=(select min(lat_e7) from locations where map_storage_id=$3), - bounds_max_lat_e7=(select max(lat_e7) from locations where map_storage_id=$3), - bounds_min_lng_e7=(select min(lng_e7) from locations where map_storage_id=$3), - bounds_max_lng_e7=(select max(lng_e7) from locations where map_storage_id=$3), + bounds_min_lat_e7=$3,bounds_max_lat_e7=$4,bounds_min_lng_e7=$5,bounds_max_lng_e7=$6, updated_at=now() where id=$1 - `, mapID, len(parsed), mapStorageID); err != nil { + `, mapID, len(parsed), boundsMinLat, boundsMaxLat, boundsMinLng, boundsMaxLng); err != nil { return contracts.CustomMap{}, err } if _, err := tx.Exec(ctx, `insert into map_aliases(alias,map_id) values($1,$2) on conflict(alias) do update set map_id=excluded.map_id`, mapKey, mapID); err != nil { @@ -248,15 +301,13 @@ func (s *pgStore) ingestCustomMap(userID, mapID, displayName, description, visib if err != nil { return contracts.CustomMap{}, err } + boundsMinLat, boundsMaxLat, boundsMinLng, boundsMaxLng := computePlayRegionBoundsE7(parsed) if _, err := tx.Exec(ctx, ` update maps set status='ready',location_count=$2,content_hash=$3,rejected_location_count=$4, - bounds_min_lat_e7=(select min(lat_e7) from locations where map_storage_id=$5), - bounds_max_lat_e7=(select max(lat_e7) from locations where map_storage_id=$5), - bounds_min_lng_e7=(select min(lng_e7) from locations where map_storage_id=$5), - bounds_max_lng_e7=(select max(lng_e7) from locations where map_storage_id=$5), + bounds_min_lat_e7=$5,bounds_max_lat_e7=$6,bounds_min_lng_e7=$7,bounds_max_lng_e7=$8, updated_at=now() where id=$1 - `, mapID, len(parsed), digestBytes, rejected, mapStorageID); err != nil { + `, mapID, len(parsed), digestBytes, rejected, boundsMinLat, boundsMaxLat, boundsMinLng, boundsMaxLng); err != nil { return contracts.CustomMap{}, err } if _, err := tx.Exec(ctx, `insert into map_aliases(alias,map_id) select map_key,id from maps where id=$1 on conflict(alias) do update set map_id=excluded.map_id`, mapID); err != nil { diff --git a/pkg/persistence/map_match_plans_test.go b/pkg/persistence/map_match_plans_test.go index 51005f4a..a7835012 100644 --- a/pkg/persistence/map_match_plans_test.go +++ b/pkg/persistence/map_match_plans_test.go @@ -56,7 +56,7 @@ func TestMapIngestComputesPlayRegionBounds(t *testing.T) { t.Fatal(err) } source := string(body) - if got := strings.Count(source, "bounds_min_lat_e7=(select min(lat_e7)"); got != 2 { + if got := strings.Count(source, "computePlayRegionBoundsE7(parsed)"); got != 2 { t.Fatalf("bounds computation on ingest = %d, want 2 (create/replace and official import)", got) } if !strings.Contains(source, "auto_zoom_play_region=$9") { @@ -64,6 +64,48 @@ func TestMapIngestComputesPlayRegionBounds(t *testing.T) { } } +func TestWrappedLngBoundsE7(t *testing.T) { + t.Run("single point", func(t *testing.T) { + start, end := wrappedLngBoundsE7([]int32{500}) + if start != 500 || end != 500 { + t.Fatalf("single point bounds = %d..%d, want 500..500", start, end) + } + }) + + t.Run("non-crossing region keeps min<=max", func(t *testing.T) { + start, end := wrappedLngBoundsE7([]int32{300_000_000, 400_000_000, 350_000_000}) + if start != 300_000_000 || end != 400_000_000 { + t.Fatalf("non-crossing bounds = %d..%d, want 300000000..400000000", start, end) + } + }) + + t.Run("antimeridian-crossing region wraps with start>end", func(t *testing.T) { + // Longitudes 177,178,179 and -179,-178 (a narrow band across +/-180). + start, end := wrappedLngBoundsE7([]int32{1_770_000_000, 1_790_000_000, -1_790_000_000, 1_780_000_000, -1_780_000_000}) + if start != 1_770_000_000 || end != -1_780_000_000 { + t.Fatalf("crossing bounds = %d..%d, want 1770000000..-1780000000", start, end) + } + if start <= end { + t.Fatal("crossing interval must have start > end") + } + }) +} + +func TestComputePlayRegionBoundsE7(t *testing.T) { + rows := []mapRow{ + {LatE7: 100, LngE7: 300_000_000}, + {LatE7: -50, LngE7: 400_000_000}, + {LatE7: 250, LngE7: 350_000_000}, + } + minLat, maxLat, minLng, maxLng := computePlayRegionBoundsE7(rows) + if minLat != -50 || maxLat != 250 { + t.Fatalf("latitude bounds = %d..%d, want -50..250", minLat, maxLat) + } + if minLng != 300_000_000 || maxLng != 400_000_000 { + t.Fatalf("longitude bounds = %d..%d, want 300000000..400000000", minLng, maxLng) + } +} + func TestSelectedMapAccessible(t *testing.T) { tests := []struct { name string