diff --git a/api/src/scripts/seed-test-countries.ts b/api/src/scripts/seed-test-countries.ts index 75bdacf..0cca930 100644 --- a/api/src/scripts/seed-test-countries.ts +++ b/api/src/scripts/seed-test-countries.ts @@ -30,6 +30,7 @@ const MINE_RESOURCE_TYPES = new Set(['iron', 'gold', 'stone']); const REQUIRED_BUILDING_TYPES = [ BuildingTypes.BAZAAR, BuildingTypes.BARRACKS, + BuildingTypes.GARDEN, BuildingTypes.MINE, ] as const; @@ -199,7 +200,7 @@ function randomBuildingsForProvince(province: Province, rng: () => number): Buil selected.push(BuildingTypes.MINE); } - const fillTypes = [BuildingTypes.BAZAAR, BuildingTypes.BARRACKS]; + const fillTypes = [BuildingTypes.BAZAAR, BuildingTypes.BARRACKS, BuildingTypes.GARDEN]; while (selected.length < cap) { selected.push(fillTypes[Math.floor(rng() * fillTypes.length)]); } diff --git a/package.json b/package.json index d9c796a..7e196ee 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "version": "1.0.0", "scripts": { + "api:local": "node scripts/local-api.js", "db:local": "node scripts/local-mysql.js up", "db:local:env": "node scripts/local-mysql.js env", "db:local:logs": "docker compose -f docker-compose.yml -f docker-compose.local-db.yml logs -f db", diff --git a/scripts/local-api.js b/scripts/local-api.js new file mode 100644 index 0000000..af7017b --- /dev/null +++ b/scripts/local-api.js @@ -0,0 +1,145 @@ +#!/usr/bin/env node + +const { existsSync, readFileSync } = require('fs'); +const { resolve } = require('path'); +const { spawnSync } = require('child_process'); + +const rootDir = resolve(__dirname, '..'); +const apiDir = resolve(rootDir, 'api'); +const apiEnvPath = resolve(apiDir, '.env'); +const npmCmd = 'npm'; +const useShell = process.platform === 'win32'; + +function readEnvFile(filePath) { + if (!existsSync(filePath)) { + return {}; + } + + const values = {}; + const lines = readFileSync(filePath, 'utf8').split(/\r?\n/); + for (const line of lines) { + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/); + if (!match) { + continue; + } + + values[match[1]] = normalizeEnvValue(match[2]); + } + + return values; +} + +function normalizeEnvValue(rawValue) { + const value = rawValue.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + + return value; +} + +function getApiEnv() { + return { + ...process.env, + ...readEnvFile(apiEnvPath), + NODE_ENV: 'development', + COOKIE_SECURE: process.env.COOKIE_SECURE || 'false', + }; +} + +const steps = [ + { + label: 'Install API dependencies', + command: npmCmd, + cwd: apiDir, + args: ['install'], + skip: () => existsSync(resolve(apiDir, 'node_modules')), + }, + { + label: 'Start local MySQL', + command: process.execPath, + cwd: rootDir, + args: [resolve(rootDir, 'scripts', 'local-mysql.js'), 'up'], + }, + { + label: 'Run API migrations', + command: npmCmd, + cwd: apiDir, + args: ['run', 'migration:run'], + useApiEnv: true, + }, + { + label: 'Seed techs', + command: npmCmd, + cwd: apiDir, + args: ['run', 'seed:techs'], + useApiEnv: true, + }, + { + label: 'Seed buildings', + command: npmCmd, + cwd: apiDir, + args: ['run', 'seed:buildings'], + useApiEnv: true, + }, + { + label: 'Seed troop types', + command: npmCmd, + cwd: apiDir, + args: ['run', 'seed:troop-types'], + useApiEnv: true, + }, + { + label: 'Import provinces', + command: npmCmd, + cwd: apiDir, + args: ['run', 'import:provinces'], + useApiEnv: true, + }, + { + label: 'Seed test countries', + command: npmCmd, + cwd: apiDir, + args: ['run', 'seed:test-countries'], + useApiEnv: true, + }, + { + label: 'Start API', + command: npmCmd, + cwd: apiDir, + args: ['run', 'start:dev'], + useApiEnv: true, + }, +]; + +function runStep(step) { + if (step.skip?.()) { + console.log(`\n==> ${step.label} (skipped)`); + return; + } + + console.log(`\n==> ${step.label}`); + const result = spawnSync(step.command, step.args, { + cwd: step.cwd, + env: step.useApiEnv ? getApiEnv() : process.env, + shell: step.command === npmCmd ? useShell : false, + stdio: 'inherit', + }); + + if (result.error) { + console.error(`Failed to run "${step.label}": ${result.error.message}`); + process.exit(1); + } + + if (result.status !== 0) { + console.error(`Step failed: ${step.label}`); + process.exit(result.status || 1); + } +} + +for (const step of steps) { + runStep(step); +} diff --git a/web-map/src/components/MapView.tsx b/web-map/src/components/MapView.tsx index 967db0d..842eb59 100644 --- a/web-map/src/components/MapView.tsx +++ b/web-map/src/components/MapView.tsx @@ -14,6 +14,7 @@ import { actionsApi } from '../api/actions.ts'; import { removeActionById } from '../store/slices/actionsSlice.ts'; import { getPendingBuildCountsByProvinceId, + getPendingProvinceBuildingIdsByProvinceId, getProvinceBuildingSlots, getProvinceEconomy, getProvinceRecruits, @@ -31,7 +32,9 @@ export const MapView = ({ loading, error }: { loading: boolean, error: string | const mapWidth = useAppSelector((state: RootState) => state.provinces.mapWidth); const armies = useAppSelector((state: RootState) => state.armies.armies); const currentUserId = useAppSelector((state: RootState) => state.user.id); + const currentUserMoney = useAppSelector((state: RootState) => state.user.money); const completedResearch = useAppSelector((state: RootState) => state.user.completedResearch); + const buildings = useAppSelector((state: RootState) => state.buildings.buildings); const mapMode = useAppSelector((state: RootState) => state.provinces.mapMode); const mapModeFilterValue = useAppSelector((state: RootState) => state.provinces.mapModeFilterValue); @@ -188,6 +191,8 @@ export const MapView = ({ loading, error }: { loading: boolean, error: string | const mapModeRenderData = useMemo(() => { const pendingBuildCountsByProvinceId = getPendingBuildCountsByProvinceId(userActions); + const pendingUpgradeBuildingIdsByProvinceId = getPendingProvinceBuildingIdsByProvinceId(userActions, ActionType.UPGRADE); + const pendingRemoveBuildingIdsByProvinceId = getPendingProvinceBuildingIdsByProvinceId(userActions, ActionType.REMOVE); const economyByProvinceId: MapModeRenderData['economyByProvinceId'] = {}; const recruitsByProvinceId: MapModeRenderData['recruitsByProvinceId'] = {}; const buildingSlotsByProvinceId: MapModeRenderData['buildingSlotsByProvinceId'] = {}; @@ -197,13 +202,20 @@ export const MapView = ({ loading, error }: { loading: boolean, error: string | for (const province of provinces) { if (province.type === 'water') continue; + if (province.userId !== currentUserId) continue; + buildingSlotsByProvinceId[province.id] = getProvinceBuildingSlots( province, pendingBuildCountsByProvinceId[province.id] ?? 0, + { + pendingUpgradeBuildingIds: pendingUpgradeBuildingIdsByProvinceId[province.id], + pendingRemoveBuildingIds: pendingRemoveBuildingIdsByProvinceId[province.id], + buildingTemplates: buildings, + userMoney: currentUserMoney, + completedResearch, + }, ); - if (province.userId !== currentUserId) continue; - const economy = getProvinceEconomy(province, completedResearch); economyByProvinceId[province.id] = economy; economyMaxAbs = Math.max(economyMaxAbs, Math.abs(economy.net)); @@ -222,7 +234,7 @@ export const MapView = ({ loading, error }: { loading: boolean, error: string | recruitsMax, buildingSlotsByProvinceId, }; - }, [userActions, provinces, currentUserId, completedResearch, mapMode, mapModeFilterValue]); + }, [userActions, provinces, currentUserId, currentUserMoney, completedResearch, buildings, mapMode, mapModeFilterValue]); // Enemy army presence per province: null = present/unknown count, number = spy-revealed total const enemyArmyInfoByProvinceId = useMemo(() => { diff --git a/web-map/src/components/ProvinceShape.tsx b/web-map/src/components/ProvinceShape.tsx index 8182c36..658e3c4 100644 --- a/web-map/src/components/ProvinceShape.tsx +++ b/web-map/src/components/ProvinceShape.tsx @@ -6,6 +6,8 @@ import type { BBox } from '../store/slices/provincesSlice'; import { BUILDING_ICONS, LANDSCAPE_ICONS, RESOURCE_ICONS } from '../constants/buildingIcons'; import type { RootState } from "../store/store.ts"; import { + BUILDING_PENDING_COLOR, + BUILDING_UPGRADE_AVAILABLE_COLOR, DEFAULT_MAP_LAND_COLOR, DEFAULT_MAP_WATER_COLOR, getCategoryModeColor, @@ -95,8 +97,11 @@ const ProvinceShapeComponent: React.FC = ({ } case 'buildings': { if (isWater) return DEFAULT_MAP_WATER_COLOR; + if (!isCurrentUserProvince) return DEFAULT_MAP_LAND_COLOR; const slots = mapModeRenderData.buildingSlotsByProvinceId[province.id]; if (!slots) return DEFAULT_MAP_LAND_COLOR; + if (slots.pendingBuilds > 0) return BUILDING_PENDING_COLOR; + if (slots.availableUpgrades > 0) return BUILDING_UPGRADE_AVAILABLE_COLOR; return slots.free > 0 ? positiveScaleColor(slots.free, Math.max(1, slots.cap)) : heatColor(-1, 1); diff --git a/web-map/src/utils/mapModes.ts b/web-map/src/utils/mapModes.ts index 1a5d509..cd86aa0 100644 --- a/web-map/src/utils/mapModes.ts +++ b/web-map/src/utils/mapModes.ts @@ -1,4 +1,4 @@ -import { ActionType, BuildingTypes, MapMode, Province } from '../types'; +import { ActionType, Building, BuildingTypes, MapMode, Province, ProvinceBuilding } from '../types'; export const MAP_MODE_OPTIONS: { value: MapMode; label: string }[] = [ { value: 'normal', label: 'Normal' }, @@ -19,6 +19,9 @@ export interface ProvinceBuildingSlots { cap: number; used: number; free: number; + pendingBuilds: number; + pendingUpgrades: number; + availableUpgrades: number; } export interface MapModeRenderData { @@ -68,6 +71,8 @@ const RESOURCE_MODE_COLORS: Record = { export const DEFAULT_MAP_LAND_COLOR = 'rgb(255, 255, 255)'; export const DEFAULT_MAP_WATER_COLOR = 'rgb(174, 226, 255)'; +export const BUILDING_PENDING_COLOR = '#facc15'; +export const BUILDING_UPGRADE_AVAILABLE_COLOR = '#a855f7'; const ZERO_HEAT_COLOR = '#fde68a'; @@ -80,6 +85,11 @@ function getActionProvinceId(action: MinimalAction): string | null { return rawId == null ? null : String(rawId); } +function getActionProvinceBuildingId(action: MinimalAction): string | null { + const rawId = action.actionData?.province_building_id ?? action.actionData?.provinceBuildingId; + return rawId == null ? null : String(rawId); +} + function mixColor(from: [number, number, number], to: [number, number, number], amount: number): string { const clamped = Math.max(0, Math.min(1, amount)); const [r1, g1, b1] = from; @@ -171,13 +181,78 @@ export function getPendingBuildCountsByProvinceId(actions: MinimalAction[]): Rec return counts; } +export function getPendingProvinceBuildingIdsByProvinceId( + actions: MinimalAction[], + actionType: ActionType.UPGRADE | ActionType.REMOVE, +): Record> { + const idsByProvinceId: Record> = {}; + for (const action of actions) { + if (action.actionType !== actionType) continue; + const provinceId = getActionProvinceId(action); + const provinceBuildingId = getActionProvinceBuildingId(action); + if (!provinceId || !provinceBuildingId) continue; + if (!idsByProvinceId[provinceId]) idsByProvinceId[provinceId] = new Set(); + idsByProvinceId[provinceId].add(provinceBuildingId); + } + return idsByProvinceId; +} + +interface ProvinceBuildingSlotOptions { + pendingUpgradeBuildingIds?: Set; + pendingRemoveBuildingIds?: Set; + buildingTemplates?: Building[]; + userMoney?: number; + completedResearch?: string[]; +} + +function canUpgradeProvinceBuilding( + province: Province, + building: ProvinceBuilding, + buildingByType: Map, + options: ProvinceBuildingSlotOptions, +): boolean { + if (!building.upgradeTo) return false; + if (options.pendingUpgradeBuildingIds?.has(building.instanceId)) return false; + if (options.pendingRemoveBuildingIds?.has(building.instanceId)) return false; + + const upgradeBuilding = buildingByType.get(building.upgradeTo); + if (!upgradeBuilding) return false; + if (upgradeBuilding.requirementBuilding && upgradeBuilding.requirementBuilding !== building.type) return false; + + const cost = Number(upgradeBuilding.cost ?? 0) + 100; + if (!Number.isFinite(cost) || !options.userMoney || options.userMoney < cost) return false; + + const allowedResources = upgradeBuilding.allowedProvinceResources; + if (allowedResources?.length && !allowedResources.includes(province.resourceType)) return false; + + const completedResearch = options.completedResearch ?? []; + const missingTech = (upgradeBuilding.requirementTech ?? []).some( + (techKey) => !completedResearch.includes(techKey), + ); + return !missingTech; +} + export function getProvinceBuildingSlots( province: Province, pendingBuildCount: number, + options: ProvinceBuildingSlotOptions = {}, ): ProvinceBuildingSlots { const cap = Math.max(0, province.buildingCap ?? 0); const used = Math.max(0, (province.buildings?.length ?? 0) + pendingBuildCount); - return { cap, used, free: Math.max(0, cap - used) }; + const pendingUpgrades = Math.max(0, options.pendingUpgradeBuildingIds?.size ?? 0); + const buildingByType = new Map((options.buildingTemplates ?? []).map((building) => [building.type, building])); + const availableUpgrades = (province.buildings ?? []).filter((building) => + canUpgradeProvinceBuilding(province, building, buildingByType, options), + ).length; + + return { + cap, + used, + free: Math.max(0, cap - used), + pendingBuilds: Math.max(0, pendingBuildCount), + pendingUpgrades, + availableUpgrades, + }; } export function getCategoryModeColor( @@ -215,7 +290,11 @@ export function getMapModeTooltip( if (renderData.mode === 'buildings') { const slots = renderData.buildingSlotsByProvinceId[province.id]; if (!slots) return null; - return `Building slots: ${slots.used}/${slots.cap} (${slots.free} free)`; + const details = [`${slots.free} free`]; + if (slots.pendingBuilds > 0) details.push(`${slots.pendingBuilds} pending build`); + if (slots.availableUpgrades > 0) details.push(`${slots.availableUpgrades} upgrade available`); + if (slots.pendingUpgrades > 0) details.push(`${slots.pendingUpgrades} pending upgrade`); + return `Building slots: ${slots.used}/${slots.cap} (${details.join(', ')})`; } return null;