Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion api/src/scripts/seed-test-countries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)]);
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
145 changes: 145 additions & 0 deletions scripts/local-api.js
Original file line number Diff line number Diff line change
@@ -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);
}
18 changes: 15 additions & 3 deletions web-map/src/components/MapView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { actionsApi } from '../api/actions.ts';
import { removeActionById } from '../store/slices/actionsSlice.ts';
import {
getPendingBuildCountsByProvinceId,
getPendingProvinceBuildingIdsByProvinceId,
getProvinceBuildingSlots,
getProvinceEconomy,
getProvinceRecruits,
Expand All @@ -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);

Expand Down Expand Up @@ -188,6 +191,8 @@ export const MapView = ({ loading, error }: { loading: boolean, error: string |

const mapModeRenderData = useMemo<MapModeRenderData>(() => {
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'] = {};
Expand All @@ -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));
Expand All @@ -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(() => {
Expand Down
5 changes: 5 additions & 0 deletions web-map/src/components/ProvinceShape.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -95,8 +97,11 @@ const ProvinceShapeComponent: React.FC<Props> = ({
}
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);
Expand Down
85 changes: 82 additions & 3 deletions web-map/src/utils/mapModes.ts
Original file line number Diff line number Diff line change
@@ -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' },
Expand All @@ -19,6 +19,9 @@ export interface ProvinceBuildingSlots {
cap: number;
used: number;
free: number;
pendingBuilds: number;
pendingUpgrades: number;
availableUpgrades: number;
}

export interface MapModeRenderData {
Expand Down Expand Up @@ -68,6 +71,8 @@ const RESOURCE_MODE_COLORS: Record<string, string> = {

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';

Expand All @@ -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;
Expand Down Expand Up @@ -171,13 +181,78 @@ export function getPendingBuildCountsByProvinceId(actions: MinimalAction[]): Rec
return counts;
}

export function getPendingProvinceBuildingIdsByProvinceId(
actions: MinimalAction[],
actionType: ActionType.UPGRADE | ActionType.REMOVE,
): Record<string, Set<string>> {
const idsByProvinceId: Record<string, Set<string>> = {};
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<string>();
idsByProvinceId[provinceId].add(provinceBuildingId);
}
return idsByProvinceId;
}

interface ProvinceBuildingSlotOptions {
pendingUpgradeBuildingIds?: Set<string>;
pendingRemoveBuildingIds?: Set<string>;
buildingTemplates?: Building[];
userMoney?: number;
completedResearch?: string[];
}

function canUpgradeProvinceBuilding(
province: Province,
building: ProvinceBuilding,
buildingByType: Map<string, Building>,
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(
Expand Down Expand Up @@ -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;
Expand Down