diff --git a/api/urls.py b/api/urls.py index 947d9a01..3b94c728 100644 --- a/api/urls.py +++ b/api/urls.py @@ -43,6 +43,7 @@ from locations.views import ( PopulationCentreMapView, + InitialMapCentreView, MapCharacterDetailView, MapViewportView, MapWorldBoundsView, @@ -135,6 +136,11 @@ def to_url(self, value): PopulationCentreMapView.as_view(), name="populationcentre-map", ), + path( + "map/initial-centre/", + InitialMapCentreView.as_view(), + name="map-initial-centre", + ), path( "map/viewport/", MapViewportView.as_view(), diff --git a/frontend/src/api/map.ts b/frontend/src/api/map.ts index 49636b66..36b7b700 100644 --- a/frontend/src/api/map.ts +++ b/frontend/src/api/map.ts @@ -1,18 +1,24 @@ // src/api/map.ts import { apiFetch } from "../utils/api"; -interface PopulationCentreListItem { - id: number; +export interface InitialMapCentre { + id: number | null; + name: string | null; + // [minX, minY, maxX, maxY] in raw EPSG:3857 metres - just enough to frame + // the camera on this village; not the full per-village feature payload + // fetchPopulationCentreMap returns (see InitialMapCentreView's docstring). + bbox: [number, number, number, number] | null; } -type PopulationCentreListResponse = - | { results?: PopulationCentreListItem[] } - | PopulationCentreListItem[]; - -export async function fetchFirstPopulationCentreId(): Promise { - const data = await apiFetch("/population-centres/"); - const list = Array.isArray(data) ? data : (data?.results ?? []); - return list.length > 0 ? list[0].id : null; +// Which village the map's camera should open on - the requesting player's +// linked character's village if they have one, otherwise an arbitrary but +// deterministic fallback (see InitialMapCentreView, locations/views.py). +// Deliberately a separate, lightweight endpoint rather than reusing +// fetchPopulationCentreMap: this only needs to get the camera pointed at the +// right place before useMapViewport (bbox-scoped, polled) takes over as the +// source of truth moments later, once the camera's first move settles. +export function fetchInitialMapCentre(): Promise { + return apiFetch("/map/initial-centre/"); } export interface PopulationCentreSummary { diff --git a/frontend/src/components/Map/Map.tsx b/frontend/src/components/Map/Map.tsx index a901897e..6c6d296a 100644 --- a/frontend/src/components/Map/Map.tsx +++ b/frontend/src/components/Map/Map.tsx @@ -43,7 +43,12 @@ import { TOOLTIP_ONLY_SELECTION_OPACITY, VILLAGE_LABEL_LAYER, } from "./layers"; -import { buildVillageSourceData, type WalkerState } from "./sourceData"; +import { + buildCharacterPointFeatures, + buildStaticVillageFeatures, + buildVillageSourceData, + type WalkerState, +} from "./sourceData"; import MapDetailCard from "../MapDetailCard/MapDetailCard"; import CharacterDetail from "../CharacterDetail/CharacterDetail"; import BuildingDetail from "../BuildingDetail/BuildingDetail"; @@ -212,6 +217,17 @@ export default function PopulationCentreMap({ [features] ); + // Styled buildings/roads/fields/boundaries - everything the map draws + // except characters. Only recomputed when `features` itself changes (each + // ~2s poll), unlike character positions, which the walker loop below + // recomputes on every animation frame - keeping this out of that loop is + // what keeps a village's buildings/roads/fields from being re-styled and + // re-reprojected 60 times a second while nothing about them has changed. + const staticVillageFeatures = useMemo( + () => buildStaticVillageFeatures(features), + [features] + ); + // Lets scatterCharacters spread a field_shelter's idle workers across the // crops Subzone(s) it services instead of clustering them at the // shelter's own small footprint - see scatterCharacters' own comment. @@ -258,16 +274,19 @@ export default function PopulationCentreMap({ const walkersRef = useRef>(new Map()); const refreshVillageSource = useCallback(() => { - sourceRef.current?.setData( - buildVillageSourceData({ - features, - characterFeatures, - idleCharacterPositions, - walkers: walkersRef.current, - now: Date.now(), - }) - ); - }, [features, characterFeatures, idleCharacterPositions]); + sourceRef.current?.setData({ + type: "FeatureCollection", + features: [ + ...staticVillageFeatures, + ...buildCharacterPointFeatures({ + characterFeatures, + idleCharacterPositions, + walkers: walkersRef.current, + now: Date.now(), + }), + ], + }); + }, [staticVillageFeatures, characterFeatures, idleCharacterPositions]); // Creates the map once. onViewportChange and refreshVillageSource are each // read via a ref inside the handlers below rather than as effect deps, so @@ -689,18 +708,19 @@ export default function PopulationCentreMap({ // Each frame recomputes position from scratch - the checkpoint plus how // much time has passed since it was taken - rather than stepping forward // from wherever the previous frame left off, so nothing compounds across - // frames or across polls (see the WalkerState comment above). + // frames or across polls (see the WalkerState comment above). Only runs + // while at least one character actually has an active journey - an idle + // village (the common case) has nothing to animate, so there's no reason + // to keep a 60fps timer alive rebuilding the source every 16ms. useEffect(() => { - const step = () => { - if (mapReady) { - refreshVillageSource(); - } - }; + if (!mapReady || walkingFeatures.length === 0) return; + + const step = () => refreshVillageSource(); step(); const intervalId = window.setInterval(step, 16); return () => window.clearInterval(intervalId); - }, [mapReady, refreshVillageSource]); + }, [mapReady, walkingFeatures.length, refreshVillageSource]); // Outlines whichever building/character the detail card currently has // open (see SELECTED_BUILDING_OUTLINE_LAYER/SELECTED_CHARACTER_HIGHLIGHT_LAYER diff --git a/frontend/src/components/Map/geojson.tsx b/frontend/src/components/Map/geojson.tsx index a2960472..b5dc544a 100644 --- a/frontend/src/components/Map/geojson.tsx +++ b/frontend/src/components/Map/geojson.tsx @@ -111,6 +111,7 @@ export function polygonTooltipContent( ); } if (properties?.feature_type === "subzone") { + if (properties?.usage === "square") return "Square"; if (properties?.usage !== "crops") return properties?.name; const stage = properties?.crop_stage as string | null | undefined; @@ -125,6 +126,12 @@ export function polygonTooltipContent( return properties?.name; } +// Open communal outdoor space (see watabou_import._import_squares) - a +// warm, paved tone distinct from both a crops Subzone's green (fieldFillFor) +// and a building's default grey, so a plaza reads as open ground rather +// than a structure. +const SQUARE_FILL_COLOR = "#d8c9a8"; + // Precomputes per-feature presentation properties (fill/stroke) so map // styling can stay simple `["get", ...]` paint expressions instead of // duplicating fieldFillFor's stage/progress logic as a style expression. @@ -135,6 +142,8 @@ export function styledPolygonFeatures(features: GeoJSONFeature[]) { const isBoundary = f.properties?.feature_type === "boundary"; const isCropSubzone = f.properties?.feature_type === "subzone" && f.properties?.usage === "crops"; + const isSquareSubzone = + f.properties?.feature_type === "subzone" && f.properties?.usage === "square"; const fillColor = isBoundary ? "transparent" : isCropSubzone @@ -142,6 +151,8 @@ export function styledPolygonFeatures(features: GeoJSONFeature[]) { f.properties?.crop_stage as string | null | undefined, f.properties?.crop_progress as number | null | undefined ) + : isSquareSubzone + ? SQUARE_FILL_COLOR : "#ddd"; return { type: "Feature" as const, diff --git a/frontend/src/components/Map/sourceData.ts b/frontend/src/components/Map/sourceData.ts index 1a0da24e..45832b43 100644 --- a/frontend/src/components/Map/sourceData.ts +++ b/frontend/src/components/Map/sourceData.ts @@ -48,22 +48,37 @@ function positionAlongPath( return pos; } -interface BuildVillageSourceDataArgs { - features: GeoJSONFeature[]; +// Styled buildings/roads/fields/boundaries - everything except characters. +// This only changes when `features` itself changes (i.e. once per ~2s poll), +// unlike character positions, which are recomputed on every animation frame +// by the walker loop in Map.tsx. Callers should memoize this separately +// (keyed on `features`) rather than folding it into buildVillageSourceData, +// so that per-frame loop isn't re-styling and re-reprojecting every building/ +// road/field 60 times a second when only the characters are actually moving. +export function buildStaticVillageFeatures(features: GeoJSONFeature[]) { + return [ + ...styledPolygonFeatures(features), + ...styledLineFeatures(features), + ...styledPointFeatures( + features.filter((feature) => feature.properties?.feature_type !== "character") + ), + ]; +} + +interface BuildCharacterPointFeaturesArgs { characterFeatures: GeoJSONFeature[]; idleCharacterPositions: Map; walkers: Map; now: number; } -export function buildVillageSourceData({ - features, +export function buildCharacterPointFeatures({ characterFeatures, idleCharacterPositions, walkers, now, -}: BuildVillageSourceDataArgs) { - const characterPointFeatures: LngLatPointFeature[] = characterFeatures.map((feature) => { +}: BuildCharacterPointFeaturesArgs): LngLatPointFeature[] { + return characterFeatures.map((feature) => { const id = String(feature.properties?.id); const walker = walkers.get(id); const rawPoint = walker @@ -83,16 +98,34 @@ export function buildVillageSourceData({ properties: feature.properties, }; }); +} +interface BuildVillageSourceDataArgs { + features: GeoJSONFeature[]; + characterFeatures: GeoJSONFeature[]; + idleCharacterPositions: Map; + walkers: Map; + now: number; +} + +// Full rebuild of the source's FeatureCollection - static features plus +// current character positions. Used on mount and whenever `features` itself +// changes; the per-frame walker loop in Map.tsx calls +// buildCharacterPointFeatures directly against a memoized +// buildStaticVillageFeatures result instead, since that loop only ever needs +// to update character positions, not the static geometry around them. +export function buildVillageSourceData({ + features, + characterFeatures, + idleCharacterPositions, + walkers, + now, +}: BuildVillageSourceDataArgs) { return { type: "FeatureCollection" as const, features: [ - ...styledPolygonFeatures(features), - ...styledLineFeatures(features), - ...styledPointFeatures( - features.filter((feature) => feature.properties?.feature_type !== "character") - ), - ...characterPointFeatures, + ...buildStaticVillageFeatures(features), + ...buildCharacterPointFeatures({ characterFeatures, idleCharacterPositions, walkers, now }), ], }; } diff --git a/frontend/src/context/GameContext.tsx b/frontend/src/context/GameContext.tsx index 3560e6b4..76b14bf7 100644 --- a/frontend/src/context/GameContext.tsx +++ b/frontend/src/context/GameContext.tsx @@ -1,12 +1,14 @@ // GameContext.tsx import { useState, useEffect, useCallback, useMemo } from 'react'; import type { ReactElement, ReactNode } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useBootstrapGameData } from '../hooks/useBootstrapGameData'; import { useEventCallback } from '../hooks/useEventCallback'; import { apiFetch } from "../utils/api"; import useActivityTimer from '../hooks/useActivityTimer'; import useUnloadWarning from '../hooks/useUnloadWarning'; +import { initialMapCentreQueryOptions, mapWorldBoundsQueryOptions } from '../hooks/useMap'; import { useAuth } from './AuthContext'; import { GameContext, type GameContextValue } from './gameContext'; import type { @@ -85,6 +87,8 @@ export const GameProvider = ({ children }: ProviderProps): ReactElement => { useUnloadWarning(activityTimer.status === 'active'); + const queryClient = useQueryClient(); + // ---------------------------------------- // STABLE CALLBACKS @@ -150,6 +154,23 @@ export const GameProvider = ({ children }: ProviderProps): ReactElement => { player?.is_premium, ]); + // Primes the map's two cheap, one-shot "where/how big is the world" + // queries as soon as fetch_info has resolved, rather than waiting for the + // player to navigate to the map page and pay for them there. Both use the + // same {queryKey, queryFn, staleTime} as useInitialMapCentre/ + // useMapWorldBounds (see useMap.ts) so this primes exactly the cache entry + // those hooks read - if the player never opens the map, the prefetched + // data just sits unused until its gcTime expires. Deliberately doesn't + // prefetch /map/viewport/: that needs a bbox only the mounted map + // component can produce, and (unlike these two) starts a 2s poll once + // enabled, which would run in the background for every session whether or + // not the player ever opens the map. + useEffect(() => { + if (loading || !isAuthenticated) return; + void queryClient.prefetchQuery(initialMapCentreQueryOptions); + void queryClient.prefetchQuery(mapWorldBoundsQueryOptions); + }, [loading, isAuthenticated, queryClient]); + const onAuthReadyFetchActivities = useEventCallback(fetchActivities); useEffect(() => { if (!authLoading && isAuthenticated) { diff --git a/frontend/src/hooks/useMap.ts b/frontend/src/hooks/useMap.ts index 411ba128..deb73979 100644 --- a/frontend/src/hooks/useMap.ts +++ b/frontend/src/hooks/useMap.ts @@ -1,7 +1,7 @@ // src/hooks/useMap.ts import { useQuery } from "@tanstack/react-query"; import { - fetchFirstPopulationCentreId, + fetchInitialMapCentre, fetchMapCharacterDetail, fetchMapViewport, fetchMapWorldBounds, @@ -13,32 +13,33 @@ import { // tracks actual journeys closely, without polling every single tick. export const MAP_POLL_INTERVAL_MS = 2000; -// Player-character linking isn't implemented yet (fetch_info deliberately -// omits it), so the map view can't key off character.population_centre_id. -// Instead it just picks the first population centre - fine while there's -// only ever the one small seeded village. -export function usePopulationCentreId() { - return useQuery({ - queryKey: ["population-centres", "first-id"], - queryFn: fetchFirstPopulationCentreId, - staleTime: 15 * 60 * 1000, - gcTime: 30 * 60 * 1000, - }); -} +// Shared {queryKey, queryFn, staleTime, gcTime} for the map's two cheap, +// one-shot "where/how big is the world" queries - exported (rather than +// inlined in useInitialMapCentre/useMapWorldBounds below) so GameContext's +// login-time prefetch (see useBootstrapGameData) primes the exact same cache +// entries these hooks read, instead of duplicating the queryKey literals and +// risking the two drifting apart. +// +// One-shot (not polled) fetch of just enough (id/name/bbox) to know where +// the camera should start - the requesting player's linked character's +// village if they have one, otherwise an arbitrary but deterministic +// fallback (see InitialMapCentreView's docstring, locations/views.py). +// Deliberately not the full per-village map payload fetchPopulationCentreMap +// returns: once the camera exists, all ongoing content comes from +// useMapViewport below instead, so there's nothing here worth paying for +// beyond the bbox to fit to. A short staleTime (rather than +// effectively-forever) means a player who links to a different character +// mid-session and revisits the map later still gets pointed at the right +// village instead of a stale cached one. +export const initialMapCentreQueryOptions = { + queryKey: ["map", "initial-centre"] as const, + queryFn: fetchInitialMapCentre, + staleTime: 5 * 60 * 1000, + gcTime: 15 * 60 * 1000, +}; -// One-shot (not polled) fetch of the single existing village's map, used -// only to derive where the camera should start (see design decision #6 in -// the map-viewport plan: initial view centres on the single seeded -// PopulationCentre until multiple villages/player-linking exist). Once the -// camera exists, all ongoing data comes from useMapViewport below instead. -export function useInitialMapCentre(pcId: number | null | undefined) { - return useQuery({ - queryKey: ["map", "population-centre", "initial-centre", pcId], - queryFn: () => fetchPopulationCentreMap(pcId as number), - enabled: pcId != null, - staleTime: 15 * 60 * 1000, - gcTime: 30 * 60 * 1000, - }); +export function useInitialMapCentre() { + return useQuery(initialMapCentreQueryOptions); } // Reads the same cache entry MapPage's prefetch effect primes for the @@ -50,7 +51,7 @@ export function useInitialMapCentre(pcId: number | null | undefined) { // the prefetch already completed; only hits the network if it hasn't. export function useTargetCentreMap(centreId: number | null) { return useQuery({ - queryKey: ["map", "population-centre", "initial-centre", centreId], + queryKey: ["map", "population-centre", "full-map", centreId], queryFn: () => fetchPopulationCentreMap(centreId as number), enabled: centreId != null, staleTime: 15 * 60 * 1000, @@ -79,13 +80,15 @@ export function useMapViewport(bbox: string | null) { // MapLibre's maxBounds (see design decision #6 in the map-viewport plan). // One-shot per session rather than polled - the world's overall extent // changes far more slowly than any individual viewport's contents. +export const mapWorldBoundsQueryOptions = { + queryKey: ["map", "world-bounds"] as const, + queryFn: fetchMapWorldBounds, + staleTime: 30 * 60 * 1000, + gcTime: 60 * 60 * 1000, +}; + export function useMapWorldBounds() { - return useQuery({ - queryKey: ["map", "world-bounds"], - queryFn: fetchMapWorldBounds, - staleTime: 30 * 60 * 1000, - gcTime: 60 * 60 * 1000, - }); + return useQuery(mapWorldBoundsQueryOptions); } // On-demand fetch for one character's map detail card (see DetailCard/ diff --git a/frontend/src/pages/MapPage/MapPage.test.tsx b/frontend/src/pages/MapPage/MapPage.test.tsx index 82a47d67..d0c8b598 100644 --- a/frontend/src/pages/MapPage/MapPage.test.tsx +++ b/frontend/src/pages/MapPage/MapPage.test.tsx @@ -5,15 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import MapPage from './MapPage'; -const mockFetchFirstPopulationCentreId = vi.fn(); +const mockFetchInitialMapCentre = vi.fn(); const mockFetchPopulationCentreMap = vi.fn(); const mockFetchMapViewport = vi.fn(); const mockFetchMapWorldBounds = vi.fn(); const mockFetchPopulationCentres = vi.fn(); vi.mock('../../api/map', () => ({ - fetchFirstPopulationCentreId: (...args: unknown[]) => - mockFetchFirstPopulationCentreId(...args), + fetchInitialMapCentre: (...args: unknown[]) => mockFetchInitialMapCentre(...args), fetchPopulationCentreMap: (...args: unknown[]) => mockFetchPopulationCentreMap(...args), fetchMapViewport: (...args: unknown[]) => mockFetchMapViewport(...args), fetchMapWorldBounds: (...args: unknown[]) => mockFetchMapWorldBounds(...args), @@ -60,12 +59,20 @@ function renderMapPage(queryClient?: QueryClient) { describe('MapPage', () => { beforeEach(() => { - mockFetchFirstPopulationCentreId.mockReset(); + mockFetchInitialMapCentre.mockReset(); mockFetchPopulationCentreMap.mockReset(); mockFetchMapViewport.mockReset(); mockFetchMapWorldBounds.mockReset(); mockFetchPopulationCentres.mockReset(); - mockFetchFirstPopulationCentreId.mockResolvedValue(1); + mockFetchInitialMapCentre.mockResolvedValue({ + id: 1, + name: 'Driftmoor', + bbox: [0, 0, 100, 100], + }); + mockFetchPopulationCentreMap.mockResolvedValue({ + meta: { population_centre_name: 'Driftmoor' }, + bbox: [0, 0, 100, 100], + }); mockFetchMapViewport.mockResolvedValue({ meta: { population_centre_name: 'Driftmoor' } }); mockFetchMapWorldBounds.mockResolvedValue({ bbox: [-1000, -1000, 1000, 1000] }); mockFetchPopulationCentres.mockResolvedValue([ @@ -77,23 +84,13 @@ describe('MapPage', () => { vi.useRealTimers(); }); - it('renders the map once the population centre and its initial map data have loaded', async () => { - mockFetchPopulationCentreMap.mockResolvedValue({ - meta: { population_centre_name: 'Driftmoor' }, - bbox: [0, 0, 100, 100], - }); - + it('renders the map once the initial map centre has loaded', async () => { renderMapPage(); expect(await screen.findByTestId('map-stub')).toHaveTextContent('Driftmoor'); }); it('reuses cached viewport data when the page is remounted within the cache window', async () => { - mockFetchPopulationCentreMap.mockResolvedValue({ - meta: { population_centre_name: 'Driftmoor' }, - bbox: [0, 0, 100, 100], - }); - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -117,10 +114,6 @@ describe('MapPage', () => { }); it('prefetches the next village map data once the village list is available', async () => { - mockFetchPopulationCentreMap.mockResolvedValue({ - meta: { population_centre_name: 'Driftmoor' }, - bbox: [0, 0, 100, 100], - }); mockFetchPopulationCentres.mockResolvedValue([ { id: 1, name: 'Driftmoor village', location: [0, 0] }, { id: 2, name: 'Cedar Hollow', location: [100, 100] }, @@ -134,11 +127,6 @@ describe('MapPage', () => { }); it('does not issue a second viewport request while the previous poll is still in flight (#624)', async () => { - mockFetchPopulationCentreMap.mockResolvedValue({ - meta: { population_centre_name: 'Driftmoor' }, - bbox: [0, 0, 100, 100], - }); - vi.useFakeTimers(); const pending: { resolve: (value: unknown) => void }[] = []; mockFetchMapViewport.mockImplementation( @@ -150,12 +138,12 @@ describe('MapPage', () => { renderMapPage(); - // Flush the population-centre lookup and initial-centre fetch, then the - // stub's onViewportChange call, so the first viewport fetch fires. This - // is a chain of several dependent async hops (pcId -> initial centre -> - // Map mounts -> onViewportChange -> viewport query starts), each of - // which may need its own microtask turn under fake timers, so flush - // repeatedly rather than assuming one pass covers it. + // Flush the initial-centre fetch, then the stub's onViewportChange call, + // so the first viewport fetch fires. This is a chain of several + // dependent async hops (initial centre -> Map mounts -> onViewportChange + // -> viewport query starts), each of which may need its own microtask + // turn under fake timers, so flush repeatedly rather than assuming one + // pass covers it. await act(async () => { await vi.runOnlyPendingTimersAsync(); }); diff --git a/frontend/src/pages/MapPage/MapPage.tsx b/frontend/src/pages/MapPage/MapPage.tsx index eecbb0ce..04a35d76 100644 --- a/frontend/src/pages/MapPage/MapPage.tsx +++ b/frontend/src/pages/MapPage/MapPage.tsx @@ -6,12 +6,12 @@ import PopulationCentreMap, { type PopulationCentreMapHandle, } from "../../components/Map/Map"; import TodayPointsBadge from "../../components/TodayPointsBadge/TodayPointsBadge"; +import { fetchPopulationCentreMap } from "../../api/map"; import FeatureToggle from "../../components/FeatureToggle"; import { useInitialMapCentre, useMapViewport, useMapWorldBounds, - usePopulationCentreId, usePopulationCentres, useTargetCentreMap, } from "../../hooks/useMap"; @@ -42,12 +42,12 @@ function HomeIcon(): React.ReactElement { export default function MapPage(): React.ReactElement { const queryClient = useQueryClient(); - const { data: pcId } = usePopulationCentreId(); - // One-time fetch of the single seeded village's map, used only to give the - // camera somewhere to start (see useInitialMapCentre) and to have - // something on screen before the camera has settled on its first - // viewport below. - const { data: initialCentre } = useInitialMapCentre(pcId); + // One-time fetch of just enough (id/name/bbox) to give the camera + // somewhere to start - the player's linked village if they have one, + // otherwise a deterministic fallback (see InitialMapCentreView). Only + // frames the camera; there's no feature data here to show before the + // camera's first viewport below settles (see the `geojson` placeholder). + const { data: initialCentre } = useInitialMapCentre(); const [bbox, setBbox] = useState(null); const { data: viewportGeojson } = useMapViewport(bbox); @@ -66,9 +66,19 @@ export default function MapPage(): React.ReactElement { // Once the map's camera has fitted itself to the initial village and // reported its first viewport (Map.tsx's onViewportChange), the // bbox-scoped viewport poll becomes the source of truth for what's on - // screen; until then, fall back to the mid-flight target (if any) or the - // one-time initial fetch. - const geojson = viewportGeojson ?? targetCentreGeojson ?? initialCentre; + // screen; until then, fall back to the mid-flight target (if any) or a + // feature-less placeholder built from the one-time initial fetch, just so + // Map.tsx has a bbox to fit its first camera position to. + const geojson = + viewportGeojson ?? + targetCentreGeojson ?? + (initialCentre?.bbox + ? { + bbox: initialCentre.bbox, + features: [], + meta: { population_centre_name: initialCentre.name }, + } + : null); const handleViewportChange = (nextBbox: string) => { setBbox(nextBbox); @@ -87,16 +97,18 @@ export default function MapPage(): React.ReactElement { : null; useEffect(() => { - if (!populationCentres?.length || !initialCentre) return; + if (!populationCentres?.length || !initialCentre?.bbox) return; const currentVillage = populationCentres[cycleIndex % populationCentres.length]; const nextVillageToPrefetch = populationCentres[(cycleIndex + 1) % populationCentres.length]; const villagesToPrefetch = [currentVillage, nextVillageToPrefetch].filter(Boolean); for (const village of villagesToPrefetch) { + // Query key must match useTargetCentreMap's, so the prefetch here and + // the read there hit the same cache entry. void queryClient.prefetchQuery({ - queryKey: ["map", "population-centre", "initial-centre", village.id], - queryFn: () => import("../../api/map").then(({ fetchPopulationCentreMap }) => fetchPopulationCentreMap(village.id)), + queryKey: ["map", "population-centre", "full-map", village.id], + queryFn: () => fetchPopulationCentreMap(village.id), staleTime: 15 * 60 * 1000, gcTime: 30 * 60 * 1000, }); @@ -117,7 +129,7 @@ export default function MapPage(): React.ReactElement { visible title (the map itself is the content). */}

{geojson?.meta?.population_centre_name || "Village map"}

- {initialCentre ? ( + {geojson ? ( str: ) def _resolve_origin(self, x: int | None, y: int | None) -> Point: + if x is None and y is None: + return self._pick_unused_layout_slot() if x is None or y is None: raise CommandError( - "Pass both --x and --y for the village's origin (or --overwrite " - "an existing centre to reuse its location)." + "Pass both --x and --y together for the village's origin, or " + "neither to auto-pick an unoccupied village_layout.VILLAGE_LAYOUT " + "slot." ) return Point(x, y, srid=3857) + + def _pick_unused_layout_slot(self) -> Point: + """ + First VILLAGE_LAYOUT slot with no existing PopulationCentre already + sitting on it - lets an ad-hoc import (e.g. trying out a village file + outside locations/data/, so outside the setup_world/import_villages + pipeline) claim spare grid space without hand-picking coordinates. + + Not persistent across a setup_world rerun: that command deletes every + existing PopulationCentre before reimporting only locations/data/'s + files (see setup_world.py), so an ad-hoc import placed here will need + to be redone afterwards - and may land on a different free slot next + time, since which slots are "unoccupied" depends on whatever other + centres exist at that moment. + """ + occupied = { + (round(centre.location.x), round(centre.location.y)) + for centre in PopulationCentre.objects.only("location") + } + for x, y in VILLAGE_LAYOUT: + if (x, y) not in occupied: + return Point(x, y, srid=3857) + raise CommandError( + f"Every village_layout.VILLAGE_LAYOUT slot ({len(VILLAGE_LAYOUT)}) is " + "already occupied by a PopulationCentre - pass --x/--y explicitly, " + "or add more slots (GRID_COLUMNS/GRID_ROWS)." + ) diff --git a/locations/migrations/0011_alter_subzone_usage.py b/locations/migrations/0011_alter_subzone_usage.py new file mode 100644 index 00000000..97401e0d --- /dev/null +++ b/locations/migrations/0011_alter_subzone_usage.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.17 on 2026-08-09 23:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("locations", "0010_building_open_time_override_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="subzone", + name="usage", + field=models.CharField( + choices=[ + ("crops", "Crop Growing"), + ("grazing", "Grazing Land"), + ("foraging", "Foraging"), + ("woodland", "Woodland"), + ("orchard", "Orchard"), + ("square", "Square"), + ("other", "Other"), + ], + default="crops", + max_length=50, + ), + ), + ] diff --git a/locations/models.py b/locations/models.py index 30b77cf9..da145baf 100644 --- a/locations/models.py +++ b/locations/models.py @@ -576,6 +576,7 @@ class Subzone(models.Model): ("foraging", "Foraging"), ("woodland", "Woodland"), ("orchard", "Orchard"), + ("square", "Square"), ("other", "Other"), ], default="crops", diff --git a/locations/services/watabou_import.py b/locations/services/watabou_import.py index 567992db..dc93a276 100644 --- a/locations/services/watabou_import.py +++ b/locations/services/watabou_import.py @@ -14,9 +14,9 @@ This only creates static geometry - PopulationCentre, Building, Road, the Node graph's CENTRE/BUILDING points plus BUILDING_ENTRANCE for non-granary -buildings, and (if the export -has a "fields" feature) a LandArea/Subzone pair per field polygon. It -deliberately does not generate Path edges: Path is the movement/pathfinding +buildings, and (if the export has a "fields" and/or "squares" feature) a +LandArea/Subzone pair per field/square polygon (see _import_polygon_subzones). +It deliberately does not generate Path edges: Path is the movement/pathfinding graph and Road is just the drawn street, so wiring the graph is left to the existing `generate_paths` command (see the `--generate-paths` flag on import_watabou_village's management command) rather than trying to derive @@ -198,19 +198,28 @@ def _translate_linestring(coordinates, offset, srid=3857) -> LineString: return LineString(points, srid=srid) -def _import_fields( - fields_feature: dict, population_centre: PopulationCentre, offset +def _import_polygon_subzones( + feature: dict, + population_centre: PopulationCentre, + offset, + *, + land_area_name: str, + subzone_name: str, + usage: str, ) -> None: """ - Create one LandArea (wrapping the whole imported field area) and one - "crops" Subzone per polygon in the "fields" MultiPolygon - unlike + Create one LandArea (wrapping the whole imported area) and one Subzone + per polygon in a watabou MultiPolygon feature - unlike generate_landarea's procedurally-synthesized Subzone geometry, these polygons are real imported shapes, so they're used directly rather than - derived from a size fraction. + derived from a size fraction. Shared by _import_fields (usage="crops") + and _import_squares (usage="square") below - the only difference + between them is naming and the usage tag, since neither FieldCrop + growth nor any other economy behaviour is usage-specific here. """ polygons = [ _translate_polygon(polygon_coords, offset) - for polygon_coords in fields_feature.get("coordinates", []) + for polygon_coords in feature.get("coordinates", []) ] if not polygons: return @@ -225,7 +234,7 @@ def _import_fields( ) land_area = LandArea.objects.create( - name=f"Fields of ({population_centre.name})", + name=land_area_name, population_centre=population_centre, location=boundary.centroid, boundary=boundary, @@ -235,14 +244,50 @@ def _import_fields( for i, polygon in enumerate(polygons): Subzone.objects.create( land_area=land_area, - name=f"Field {i + 1} of ({population_centre.name})", + name=f"{subzone_name} {i + 1} of ({population_centre.name})", location=polygon.centroid, boundary=polygon, size=polygon.area / SQUARE_METRES_PER_HECTARE, - usage="crops", + usage=usage, ) +def _import_fields( + fields_feature: dict, population_centre: PopulationCentre, offset +) -> None: + _import_polygon_subzones( + fields_feature, + population_centre, + offset, + land_area_name=f"Fields of ({population_centre.name})", + subzone_name="Field", + usage="crops", + ) + + +def _import_squares( + squares_feature: dict, population_centre: PopulationCentre, offset +) -> None: + """ + Import watabou's "squares" feature - open communal outdoor space (a + market square/plaza) rather than property. Modelled the same way as a + crops Subzone (see _import_polygon_subzones) since LandArea is already + documented as "not property... a communal or functional area", but + tagged usage="square" instead of "crops": squares have no FieldCrop + growth cycle or other economy behaviour attached, they're purely a map + feature (see SubzoneFeatureSerializer, which already returns None for + every crop_* property when there's no attached FieldCrop). + """ + _import_polygon_subzones( + squares_feature, + population_centre, + offset, + land_area_name=f"Squares of ({population_centre.name})", + subzone_name="Square", + usage="square", + ) + + @transaction.atomic def import_watabou_village(data: dict, *, name: str, origin: Point) -> PopulationCentre: """ @@ -258,6 +303,7 @@ def import_watabou_village(data: dict, *, name: str, origin: Point) -> Populatio districts_feature = _feature_by_id(data, "districts") earth_feature = _feature_by_id(data, "earth") fields_feature = _feature_by_id(data, "fields") + squares_feature = _feature_by_id(data, "squares") # Districts are the actual town/village extent, each a named ward - # prefer their union over the "earth" feature, which is just the @@ -360,6 +406,9 @@ def import_watabou_village(data: dict, *, name: str, origin: Point) -> Populatio if fields_feature and fields_feature.get("type") == "MultiPolygon": _import_fields(fields_feature, population_centre, offset) + if squares_feature and squares_feature.get("type") == "MultiPolygon": + _import_squares(squares_feature, population_centre, offset) + # Compute-and-log only for now (see population_estimation's module # docstring and .claude/plans/village-capacity-sizing-plan.md step 3) - # this doesn't yet change which buildings get created. It's here to diff --git a/locations/tests/test_import_village_command.py b/locations/tests/test_import_village_command.py new file mode 100644 index 00000000..707d273e --- /dev/null +++ b/locations/tests/test_import_village_command.py @@ -0,0 +1,73 @@ +from django.contrib.gis.geos import Point +from django.core.management.base import CommandError +from django.test import TestCase + +from locations.management.commands.import_village import Command +from locations.models import PopulationCentre +from locations.village_layout import VILLAGE_LAYOUT + + +class ResolveOriginTest(TestCase): + def setUp(self): + self.command = Command() + + def test_both_x_and_y_given_uses_them_directly(self): + origin = self.command._resolve_origin(123, 456) + self.assertEqual(origin, Point(123, 456, srid=3857)) + + def test_only_x_given_raises(self): + with self.assertRaises(CommandError): + self.command._resolve_origin(123, None) + + def test_only_y_given_raises(self): + with self.assertRaises(CommandError): + self.command._resolve_origin(None, 456) + + def test_neither_given_auto_picks_a_slot(self): + origin = self.command._resolve_origin(None, None) + x, y = VILLAGE_LAYOUT[0] + self.assertEqual(origin, Point(x, y, srid=3857)) + + +class PickUnusedLayoutSlotTest(TestCase): + def setUp(self): + self.command = Command() + + def test_picks_first_slot_when_none_occupied(self): + origin = self.command._pick_unused_layout_slot() + x, y = VILLAGE_LAYOUT[0] + self.assertEqual(origin, Point(x, y, srid=3857)) + + def test_skips_occupied_slots(self): + first_x, first_y = VILLAGE_LAYOUT[0] + second_x, second_y = VILLAGE_LAYOUT[1] + PopulationCentre.objects.create( + name="Occupied village", + location=Point(first_x, first_y, srid=3857), + ) + + origin = self.command._pick_unused_layout_slot() + + self.assertEqual(origin, Point(second_x, second_y, srid=3857)) + + def test_raises_when_every_slot_is_occupied(self): + for i, (x, y) in enumerate(VILLAGE_LAYOUT): + PopulationCentre.objects.create( + name=f"Village {i}", + location=Point(x, y, srid=3857), + ) + + with self.assertRaises(CommandError): + self.command._pick_unused_layout_slot() + + def test_ignores_centres_not_on_a_layout_slot(self): + # A centre placed off-grid (e.g. hand-picked --x/--y) shouldn't + # affect which layout slots count as unoccupied. + PopulationCentre.objects.create( + name="Off-grid village", + location=Point(999_999, 999_999, srid=3857), + ) + + origin = self.command._pick_unused_layout_slot() + x, y = VILLAGE_LAYOUT[0] + self.assertEqual(origin, Point(x, y, srid=3857)) diff --git a/locations/tests/test_initial_map_centre_view.py b/locations/tests/test_initial_map_centre_view.py new file mode 100644 index 00000000..b757e2ee --- /dev/null +++ b/locations/tests/test_initial_map_centre_view.py @@ -0,0 +1,91 @@ +from django.contrib.gis.geos import Point, Polygon +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from character.models import Character, PlayerCharacterLink +from locations.models import PopulationCentre +from users.tests import user_factory + + +def _boundary(cx, cy, half=50): + return Polygon( + ( + (cx - half, cy - half), + (cx + half, cy - half), + (cx + half, cy + half), + (cx - half, cy + half), + (cx - half, cy - half), + ), + srid=3857, + ) + + +class InitialMapCentreViewTest(TestCase): + """InitialMapCentreView (`/map/initial-centre/`) picks which village the + map's camera opens on, and is expected to prefer the requesting player's + linked character's village over the arbitrary "first" fallback - see the + view's own docstring.""" + + def setUp(self): + self.first_centre = PopulationCentre.objects.create( + name="First village", + location=Point(0, 0, srid=3857), + boundary=_boundary(0, 0), + ) + self.linked_centre = PopulationCentre.objects.create( + name="Linked village", + location=Point(5000, 5000, srid=3857), + boundary=_boundary(5000, 5000), + ) + self.client = APIClient() + + def test_falls_back_to_lowest_pk_centre_with_no_active_link(self): + user = user_factory(with_player=True) + self.client.force_authenticate(user=user) + + response = self.client.get(reverse("map-initial-centre")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], self.first_centre.id) + self.assertEqual(response.data["name"], self.first_centre.name) + self.assertEqual( + list(response.data["bbox"]), list(self.first_centre.boundary.extent) + ) + + def test_prefers_the_active_links_character_population_centre(self): + user = user_factory(with_player=True) + character = Character.objects.create( + given_name="Linked", + location=Point(5000, 5000, srid=3857), + population_centre=self.linked_centre, + ) + PlayerCharacterLink.objects.create(player=user.player, character=character) + self.client.force_authenticate(user=user) + + response = self.client.get(reverse("map-initial-centre")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], self.linked_centre.id) + self.assertEqual(response.data["name"], self.linked_centre.name) + self.assertEqual( + list(response.data["bbox"]), list(self.linked_centre.boundary.extent) + ) + + def test_no_boundary_falls_back_to_a_window_around_location(self): + PopulationCentre.objects.all().delete() + centre = PopulationCentre.objects.create( + name="Boundaryless village", location=Point(100, 200, srid=3857) + ) + user = user_factory(with_player=True) + self.client.force_authenticate(user=user) + + response = self.client.get(reverse("map-initial-centre")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], centre.id) + min_x, min_y, max_x, max_y = response.data["bbox"] + self.assertLess(min_x, 100) + self.assertGreater(max_x, 100) + self.assertLess(min_y, 200) + self.assertGreater(max_y, 200) diff --git a/locations/tests/test_map_serializers.py b/locations/tests/test_map_serializers.py index 824d87ab..adcdae09 100644 --- a/locations/tests/test_map_serializers.py +++ b/locations/tests/test_map_serializers.py @@ -347,6 +347,21 @@ def test_ready_stage(self): self.assertEqual(props["crop_stage"], "ready") self.assertIsNone(props["crop_progress"]) + def test_square_usage_has_no_crop_properties(self): + square = Subzone.objects.create( + name="Testville - Square", + land_area=self.subzone.land_area, + usage="square", + size=0.2, + boundary=SQUARE, + ) + square = Subzone.objects.select_related("field_crop").get(pk=square.pk) + props = SubzoneFeatureSerializer(square).data["properties"] + self.assertEqual(props["usage"], "square") + self.assertIsNone(props["crop_stage"]) + self.assertIsNone(props["crop_progress"]) + self.assertIsNone(props["shelter_building_id"]) + class PopulationCentreLabelFeatureSerializerTest(TestCase): """ diff --git a/locations/tests/test_watabou_import.py b/locations/tests/test_watabou_import.py index ea8dfe25..495a9a7a 100644 --- a/locations/tests/test_watabou_import.py +++ b/locations/tests/test_watabou_import.py @@ -30,7 +30,13 @@ def _make_export( - *, districts=None, buildings=None, roads=None, road_width=None, fields=None + *, + districts=None, + buildings=None, + roads=None, + road_width=None, + fields=None, + squares=None, ): features = [ {"type": "Feature", "id": "earth", **EARTH}, @@ -51,6 +57,10 @@ def _make_export( features.append({"type": "Feature", "id": "districts", "geometries": districts}) if fields is not None: features.append({"type": "MultiPolygon", "id": "fields", "coordinates": fields}) + if squares is not None: + features.append( + {"type": "MultiPolygon", "id": "squares", "coordinates": squares} + ) return {"features": features} @@ -348,3 +358,54 @@ def test_empty_fields_coordinates_creates_no_land_area(self): centre = import_watabou_village(data, name="Empty Fields", origin=origin) self.assertFalse(LandArea.objects.filter(population_centre=centre).exists()) + + +# Reuses FIELD_ONE/FIELD_TWO's shapes for the "squares" MultiPolygon too - +# same convention, different feature id/usage. +SQUARE_ONE = FIELD_ONE +SQUARE_TWO = FIELD_TWO + + +class WatabouImportSquaresTest(TestCase): + def test_creates_one_square_subzone_per_square_polygon(self): + data = _make_export( + districts=[TRADE_DISTRICT, MILL_WARD], squares=[SQUARE_ONE, SQUARE_TWO] + ) + origin = Point(0, 0, srid=3857) + + centre = import_watabou_village(data, name="Plaza Wards", origin=origin) + + land_area = LandArea.objects.get( + population_centre=centre, name__startswith="Squares" + ) + subzones = list(land_area.subzones.all()) + self.assertEqual(len(subzones), 2) + self.assertTrue(all(s.usage == "square" for s in subzones)) + + def test_squares_and_fields_create_separate_land_areas(self): + data = _make_export( + districts=[TRADE_DISTRICT, MILL_WARD], + fields=[FIELD_ONE], + squares=[SQUARE_TWO], + ) + origin = Point(0, 0, srid=3857) + + centre = import_watabou_village(data, name="Mixed Wards", origin=origin) + + self.assertEqual(LandArea.objects.filter(population_centre=centre).count(), 2) + crop_subzone = Subzone.objects.get(usage="crops") + square_subzone = Subzone.objects.get(usage="square") + self.assertNotEqual(crop_subzone.land_area_id, square_subzone.land_area_id) + + def test_no_squares_feature_creates_no_square_subzone(self): + data = _make_export(districts=[TRADE_DISTRICT, MILL_WARD]) + origin = Point(0, 0, srid=3857) + + centre = import_watabou_village(data, name="No Squares", origin=origin) + + self.assertFalse( + LandArea.objects.filter( + population_centre=centre, name__startswith="Squares" + ).exists() + ) + self.assertFalse(Subzone.objects.filter(usage="square").exists()) diff --git a/locations/views.py b/locations/views.py index 943196b7..386c2aba 100644 --- a/locations/views.py +++ b/locations/views.py @@ -73,9 +73,15 @@ def get(self, request, pk): "character_locations", "goods_stocks" ) ) - crop_subzones = list( + # "crops" drives FieldCrop growth-cycle rendering (see + # SubzoneFeatureSerializer); "square" is purely a communal-space map + # feature with no economy behaviour attached (see + # watabou_import._import_squares) - both are just polygons on the + # map, so they're queried and serialized together. + visible_subzones = list( Subzone.objects.filter( - land_area__population_centre=population_centre, usage="crops" + land_area__population_centre=population_centre, + usage__in=["crops", "square"], ).select_related("field_crop") ) @@ -109,7 +115,7 @@ def get(self, request, pk): features.append(BoundaryFeatureSerializer(population_centre).data) features.extend(CharacterPointFeatureSerializer(characters, many=True).data) features.extend(BuildingFeatureSerializer(buildings, many=True).data) - features.extend(SubzoneFeatureSerializer(crop_subzones, many=True).data) + features.extend(SubzoneFeatureSerializer(visible_subzones, many=True).data) features.extend(PathFeatureSerializer(paths, many=True).data) features.extend(RoadFeatureSerializer(roads, many=True).data) @@ -120,7 +126,7 @@ def get(self, request, pk): ) for polygon_obj, polygon_attr in [ *((b, "footprint") for b in buildings), - *((s, "boundary") for s in crop_subzones), + *((s, "boundary") for s in visible_subzones), *((r, "geom") for r in roads), ]: geom = getattr(polygon_obj, polygon_attr) @@ -146,6 +152,54 @@ def get(self, request, pk): ) +class InitialMapCentreView(APIView): + """ + Picks which PopulationCentre the map's camera should open on and returns + just enough to frame it there - id/name/bbox, not the full per-village + payload (buildings/characters/roads/fields) PopulationCentreMapView + returns. That fuller payload is unnecessary here: MapViewportView takes + over as the source of truth within moments, once the camera's first + "moveend" fires (see Map.tsx's initial-fit effect), so this only needs to + get the camera pointed at the right place cheaply. + + Prefers the PopulationCentre containing the requesting player's linked + character (see PlayerCharacterLink/Player.active_link) - the village the + player actually cares about. Falls back to the lowest-pk PopulationCentre + when there's no active link (e.g. player-character linking hasn't + happened yet), same "just pick one, deterministically" behaviour used + before per-player villages existed here. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + population_centre = None + + active_link = request.user.player.active_link + if active_link and active_link.character.population_centre_id: + population_centre = active_link.character.population_centre + + if population_centre is None: + population_centre = PopulationCentre.objects.order_by("pk").first() + + if population_centre is None: + return Response({"id": None, "name": None, "bbox": None}) + + if population_centre.boundary: + bbox = list(population_centre.boundary.extent) + else: + # Mirrors PopulationCentreMapView's own null-boundary guard - a + # centre with no boundary polygon yet (e.g. in tests) still has a + # location point to frame a small window around. + x, y = population_centre.location.x, population_centre.location.y + pad = WORLD_BOUNDS_PADDING_M + bbox = [x - pad, y - pad, x + pad, y + pad] + + return Response( + {"id": population_centre.id, "name": population_centre.name, "bbox": bbox} + ) + + class MapViewportView(APIView): """ Cross-village map endpoint: returns every map feature whose geometry @@ -174,9 +228,13 @@ def get(self, request): footprint__isnull=False, footprint__bboverlaps=bbox ).prefetch_related("character_locations", "goods_stocks") ) - crop_subzones = list( + # See PopulationCentreMapView's matching comment - "crops" and + # "square" are both just polygon map features, queried together. + visible_subzones = list( Subzone.objects.filter( - usage="crops", boundary__isnull=False, boundary__bboverlaps=bbox + usage__in=["crops", "square"], + boundary__isnull=False, + boundary__bboverlaps=bbox, ).select_related("field_crop") ) paths = ( @@ -214,7 +272,7 @@ def get(self, request): features.extend(CharacterPointFeatureSerializer(characters, many=True).data) features.extend(BuildingFeatureSerializer(buildings, many=True).data) - features.extend(SubzoneFeatureSerializer(crop_subzones, many=True).data) + features.extend(SubzoneFeatureSerializer(visible_subzones, many=True).data) features.extend(PathFeatureSerializer(paths, many=True).data) features.extend(RoadFeatureSerializer(roads, many=True).data)