From 2ee48ee7a4e6674fb06e36ad54d230439b20093d Mon Sep 17 00:00:00 2001 From: Prayuj Tuli Date: Tue, 25 Aug 2026 22:25:41 -0400 Subject: [PATCH 1/3] feat: add standalone defensive structure controls --- proto/cityio/entity/v1/battle.proto | 2 + proto/cityio/entity/v1/building.proto | 5 +- proto/cityio/entity/v1/common.proto | 2 + proto/cityio/service/v1/building.proto | 2 + proto/cityio/service/v1/config.proto | 5 + src/lib/game/sprites.ts | 52 ++++++++- src/lib/gen/cityio/entity/v1/battle_pb.ts | 48 ++++---- src/lib/gen/cityio/entity/v1/building_pb.ts | 14 ++- src/lib/gen/cityio/entity/v1/common_pb.ts | 12 +- src/lib/gen/cityio/service/v1/building_pb.ts | 4 + src/lib/gen/cityio/service/v1/config_pb.ts | 84 +++++++++----- src/lib/stores.ts | 3 +- src/routes/game/+page.svelte | 114 ++++++++++++++++--- 13 files changed, 273 insertions(+), 74 deletions(-) diff --git a/proto/cityio/entity/v1/battle.proto b/proto/cityio/entity/v1/battle.proto index 2c199c1..4f821de 100644 --- a/proto/cityio/entity/v1/battle.proto +++ b/proto/cityio/entity/v1/battle.proto @@ -24,6 +24,8 @@ message BattleSide { int64 starting_militia_count = 8; BattleLossSummary cumulative_losses = 9; BattleLossSummary last_round_losses = 10; + // Effective durability added by a settlement center and/or owned fort. + int32 defense_bonus_percent = 11; } // Battle exists only while combat is active. Each side can contain armies diff --git a/proto/cityio/entity/v1/building.proto b/proto/cityio/entity/v1/building.proto index fd9dc47..1fdccff 100644 --- a/proto/cityio/entity/v1/building.proto +++ b/proto/cityio/entity/v1/building.proto @@ -6,7 +6,7 @@ import "cityio/entity/v1/common.proto"; import "cityio/entity/v1/ids.proto"; import "google/protobuf/timestamp.proto"; -// Building is a structure within a city. +// Building is either part of a city or a standalone player-owned structure. message Building { BuildingId building_id = 1; CityId city_id = 2; @@ -16,4 +16,7 @@ message Building { Coordinates coords = 6; optional google.protobuf.Timestamp construction_start = 7; optional google.protobuf.Timestamp construction_end = 8; + // Set only for standalone structures. City buildings inherit ownership from + // their city and continue to use city_id. + optional UserId owner = 9; } diff --git a/proto/cityio/entity/v1/common.proto b/proto/cityio/entity/v1/common.proto index 07298bb..e6ad775 100644 --- a/proto/cityio/entity/v1/common.proto +++ b/proto/cityio/entity/v1/common.proto @@ -18,6 +18,8 @@ enum BuildingType { BUILDING_TYPE_HOUSE = 4; BUILDING_TYPE_FARM = 5; BUILDING_TYPE_MINE = 6; + BUILDING_TYPE_WATCHTOWER = 7; + BUILDING_TYPE_FORT = 8; } // TroopType identifies a kind of troop. diff --git a/proto/cityio/service/v1/building.proto b/proto/cityio/service/v1/building.proto index 70e2045..66f73df 100644 --- a/proto/cityio/service/v1/building.proto +++ b/proto/cityio/service/v1/building.proto @@ -7,6 +7,7 @@ import "cityio/entity/v1/common.proto"; import "cityio/entity/v1/ids.proto"; message CreateBuildingRequest { + // Required for city buildings and omitted for standalone watchtowers/forts. cityio.entity.v1.CityId city_id = 1; cityio.entity.v1.BuildingType type = 2; cityio.entity.v1.Coordinates coords = 3; @@ -33,6 +34,7 @@ message DeleteBuildingRequest { message DeleteBuildingResponse {} message ListBuildingsRequest { + // When omitted, returns the caller's standalone structures. cityio.entity.v1.CityId city_id = 1; } message ListBuildingsResponse { diff --git a/proto/cityio/service/v1/config.proto b/proto/cityio/service/v1/config.proto index 35d392c..7255141 100644 --- a/proto/cityio/service/v1/config.proto +++ b/proto/cityio/service/v1/config.proto @@ -25,6 +25,10 @@ message BuildingLevelStats { double population = 5; // Barracks only: throughput relative to a level-one training lane. double training_speed_multiplier = 6; + // Watchtower only: Chebyshev map-vision radius at this completed level. + int32 vision_radius = 7; + // Centers and forts: effective durability added during eligible battles. + int32 defense_bonus_percent = 8; } message BuildingConfig { @@ -58,6 +62,7 @@ message GetGameConfigResponse { repeated BuildingConfig buildings = 5; google.protobuf.Duration city_tick = 6; PopulationPolicyConfig population_policy = 7; + int32 structure_placement_radius = 8; } service ConfigService { diff --git a/src/lib/game/sprites.ts b/src/lib/game/sprites.ts index 4c9324d..b85cc48 100644 --- a/src/lib/game/sprites.ts +++ b/src/lib/game/sprites.ts @@ -4,7 +4,7 @@ import { tileHash } from './colors'; export type TerrainKind = 'grassland' | 'plains' | 'forest' | 'hills' | 'mountains' | 'desert' | 'marsh' | 'water' | 'fog'; export type TerrainNeighbors = readonly [TerrainKind | null, TerrainKind | null, TerrainKind | null, TerrainKind | null]; -export type StructureKind = 'house' | 'farm' | 'mine' | 'barracks' | 'city_center' | 'town_center'; +export type StructureKind = 'house' | 'farm' | 'mine' | 'barracks' | 'watchtower' | 'fort' | 'city_center' | 'town_center'; const PAD = 2; const W = 2 * HW + PAD * 2; @@ -52,6 +52,8 @@ const STRUCTURE_HEADROOM: Record = { mine: 14, house: 24, barracks: 28, + watchtower: 42, + fort: 30, town_center: 32, city_center: 42 }; @@ -320,6 +322,48 @@ function drawBarracks(ctx: CanvasRenderingContext2D, cx: number, cy: number) { ctx.fillRect(cx - 2, baseY - 5, 4, 7); } +function drawWatchtower(ctx: CanvasRenderingContext2D, cx: number, cy: number) { + const baseY = cy - 2; + drawStructureShadow(ctx, cx, cy, 10); + ctx.fillStyle = '#76563c'; + ctx.fillRect(cx - 7, baseY - 27, 4, 29); + ctx.fillRect(cx + 3, baseY - 27, 4, 29); + ctx.strokeStyle = '#4d3524'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx - 6, baseY - 20); + ctx.lineTo(cx + 6, baseY - 5); + ctx.moveTo(cx + 6, baseY - 20); + ctx.lineTo(cx - 6, baseY - 5); + ctx.stroke(); + ctx.fillStyle = '#9a7650'; + ctx.fillRect(cx - 11, baseY - 31, 22, 7); + pitchedRoof(ctx, cx, baseY - 31, 13, 8, 0x5c3926); + ctx.fillStyle = '#32251a'; + ctx.fillRect(cx - 2, baseY - 29, 4, 3); +} + +function drawFort(ctx: CanvasRenderingContext2D, cx: number, cy: number) { + const baseY = cy - 2; + drawStructureShadow(ctx, cx, cy, 17); + ctx.fillStyle = '#8f8b7c'; + ctx.fillRect(cx - 16, baseY - 16, 32, 18); + ctx.fillStyle = '#777568'; + ctx.fillRect(cx + 3, baseY - 16, 13, 18); + ctx.fillStyle = '#aaa594'; + for (let x = -16; x <= 11; x += 9) ctx.fillRect(cx + x, baseY - 21, 6, 6); + ctx.fillStyle = '#4c4438'; + ctx.fillRect(cx - 4, baseY - 7, 8, 9); + ctx.strokeStyle = 'rgba(55,52,45,.55)'; + ctx.lineWidth = 1; + for (let y = baseY - 12; y < baseY; y += 5) { + ctx.beginPath(); + ctx.moveTo(cx - 15, y); + ctx.lineTo(cx + 15, y); + ctx.stroke(); + } +} + function drawTownCenter(ctx: CanvasRenderingContext2D, cx: number, cy: number) { const baseY = cy - 2; drawStructureShadow(ctx, cx, cy, 16); @@ -438,6 +482,12 @@ function renderStructure(kind: StructureKind): HTMLCanvasElement { case 'barracks': drawBarracks(ctx, cx, cy); break; + case 'watchtower': + drawWatchtower(ctx, cx, cy); + break; + case 'fort': + drawFort(ctx, cx, cy); + break; case 'town_center': drawTownCenter(ctx, cx, cy); break; diff --git a/src/lib/gen/cityio/entity/v1/battle_pb.ts b/src/lib/gen/cityio/entity/v1/battle_pb.ts index d1d2d05..c6577f9 100644 --- a/src/lib/gen/cityio/entity/v1/battle_pb.ts +++ b/src/lib/gen/cityio/entity/v1/battle_pb.ts @@ -2,30 +2,26 @@ // @generated from file cityio/entity/v1/battle.proto (package cityio.entity.v1, syntax proto3) /* eslint-disable */ -import type { GenFile, GenMessage } from '@bufbuild/protobuf/codegenv2'; -import { fileDesc, messageDesc } from '@bufbuild/protobuf/codegenv2'; -import type { TroopStack } from './army_pb'; -import { file_cityio_entity_v1_army } from './army_pb'; -import type { ArmyId, BattleId, CityId, TileId, UserId } from './ids_pb'; -import { file_cityio_entity_v1_ids } from './ids_pb'; -import type { Timestamp } from '@bufbuild/protobuf/wkt'; -import { file_google_protobuf_timestamp } from '@bufbuild/protobuf/wkt'; -import type { Message } from '@bufbuild/protobuf'; +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { TroopStack } from "./army_pb"; +import { file_cityio_entity_v1_army } from "./army_pb"; +import type { ArmyId, BattleId, CityId, TileId, UserId } from "./ids_pb"; +import { file_cityio_entity_v1_ids } from "./ids_pb"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; /** * Describes the file cityio/entity/v1/battle.proto. */ -export const file_cityio_entity_v1_battle: GenFile = - /*@__PURE__*/ - fileDesc( - 'Ch1jaXR5aW8vZW50aXR5L3YxL2JhdHRsZS5wcm90bxIQY2l0eWlvLmVudGl0eS52MSJlChFCYXR0bGVMb3NzU3VtbWFyeRIsCgZ0cm9vcHMYASADKAsyHC5jaXR5aW8uZW50aXR5LnYxLlRyb29wU3RhY2sSDwoHbWlsaXRpYRgCIAEoAxIRCgljaXZpbGlhbnMYAyABKAMi8AMKCkJhdHRsZVNpZGUSKgoIdXNlcl9pZHMYASADKAsyGC5jaXR5aW8uZW50aXR5LnYxLlVzZXJJZBIqCghhcm15X2lkcxgCIAMoCzIYLmNpdHlpby5lbnRpdHkudjEuQXJteUlkEjYKD21pbGl0aWFfY2l0eV9pZBgDIAEoCzIYLmNpdHlpby5lbnRpdHkudjEuQ2l0eUlkSACIAQESFQoNbWlsaXRpYV9jb3VudBgEIAEoAxIYChBzdHJlbmd0aF92aXNpYmxlGAUgASgIEjUKD3N0YXJ0aW5nX3Ryb29wcxgGIAMoCzIcLmNpdHlpby5lbnRpdHkudjEuVHJvb3BTdGFjaxI2ChBzdXJ2aXZpbmdfdHJvb3BzGAcgAygLMhwuY2l0eWlvLmVudGl0eS52MS5Ucm9vcFN0YWNrEh4KFnN0YXJ0aW5nX21pbGl0aWFfY291bnQYCCABKAMSPgoRY3VtdWxhdGl2ZV9sb3NzZXMYCSABKAsyIy5jaXR5aW8uZW50aXR5LnYxLkJhdHRsZUxvc3NTdW1tYXJ5Ej4KEWxhc3Rfcm91bmRfbG9zc2VzGAogASgLMiMuY2l0eWlvLmVudGl0eS52MS5CYXR0bGVMb3NzU3VtbWFyeUISChBfbWlsaXRpYV9jaXR5X2lkIsACCgZCYXR0bGUSLQoJYmF0dGxlX2lkGAEgASgLMhouY2l0eWlvLmVudGl0eS52MS5CYXR0bGVJZBIpCgd0aWxlX2lkGAIgASgLMhguY2l0eWlvLmVudGl0eS52MS5UaWxlSWQSLwoJYXR0YWNrZXJzGAMgASgLMhwuY2l0eWlvLmVudGl0eS52MS5CYXR0bGVTaWRlEi8KCWRlZmVuZGVycxgEIAEoCzIcLmNpdHlpby5lbnRpdHkudjEuQmF0dGxlU2lkZRIuCgpzdGFydGVkX2F0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIwCgxuZXh0X3RpY2tfYXQYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhgKEGNvbXBsZXRlZF9yb3VuZHMYByABKAViBnByb3RvMw', - [file_cityio_entity_v1_army, file_cityio_entity_v1_ids, file_google_protobuf_timestamp] - ); +export const file_cityio_entity_v1_battle: GenFile = /*@__PURE__*/ + fileDesc("Ch1jaXR5aW8vZW50aXR5L3YxL2JhdHRsZS5wcm90bxIQY2l0eWlvLmVudGl0eS52MSJlChFCYXR0bGVMb3NzU3VtbWFyeRIsCgZ0cm9vcHMYASADKAsyHC5jaXR5aW8uZW50aXR5LnYxLlRyb29wU3RhY2sSDwoHbWlsaXRpYRgCIAEoAxIRCgljaXZpbGlhbnMYAyABKAMijwQKCkJhdHRsZVNpZGUSKgoIdXNlcl9pZHMYASADKAsyGC5jaXR5aW8uZW50aXR5LnYxLlVzZXJJZBIqCghhcm15X2lkcxgCIAMoCzIYLmNpdHlpby5lbnRpdHkudjEuQXJteUlkEjYKD21pbGl0aWFfY2l0eV9pZBgDIAEoCzIYLmNpdHlpby5lbnRpdHkudjEuQ2l0eUlkSACIAQESFQoNbWlsaXRpYV9jb3VudBgEIAEoAxIYChBzdHJlbmd0aF92aXNpYmxlGAUgASgIEjUKD3N0YXJ0aW5nX3Ryb29wcxgGIAMoCzIcLmNpdHlpby5lbnRpdHkudjEuVHJvb3BTdGFjaxI2ChBzdXJ2aXZpbmdfdHJvb3BzGAcgAygLMhwuY2l0eWlvLmVudGl0eS52MS5Ucm9vcFN0YWNrEh4KFnN0YXJ0aW5nX21pbGl0aWFfY291bnQYCCABKAMSPgoRY3VtdWxhdGl2ZV9sb3NzZXMYCSABKAsyIy5jaXR5aW8uZW50aXR5LnYxLkJhdHRsZUxvc3NTdW1tYXJ5Ej4KEWxhc3Rfcm91bmRfbG9zc2VzGAogASgLMiMuY2l0eWlvLmVudGl0eS52MS5CYXR0bGVMb3NzU3VtbWFyeRIdChVkZWZlbnNlX2JvbnVzX3BlcmNlbnQYCyABKAVCEgoQX21pbGl0aWFfY2l0eV9pZCLAAgoGQmF0dGxlEi0KCWJhdHRsZV9pZBgBIAEoCzIaLmNpdHlpby5lbnRpdHkudjEuQmF0dGxlSWQSKQoHdGlsZV9pZBgCIAEoCzIYLmNpdHlpby5lbnRpdHkudjEuVGlsZUlkEi8KCWF0dGFja2VycxgDIAEoCzIcLmNpdHlpby5lbnRpdHkudjEuQmF0dGxlU2lkZRIvCglkZWZlbmRlcnMYBCABKAsyHC5jaXR5aW8uZW50aXR5LnYxLkJhdHRsZVNpZGUSLgoKc3RhcnRlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMAoMbmV4dF90aWNrX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIYChBjb21wbGV0ZWRfcm91bmRzGAcgASgFYgZwcm90bzM", [file_cityio_entity_v1_army, file_cityio_entity_v1_ids, file_google_protobuf_timestamp]); /** * @generated from message cityio.entity.v1.BattleLossSummary */ -export type BattleLossSummary = Message<'cityio.entity.v1.BattleLossSummary'> & { +export type BattleLossSummary = Message<"cityio.entity.v1.BattleLossSummary"> & { /** * @generated from field: repeated cityio.entity.v1.TroopStack troops = 1; */ @@ -46,12 +42,13 @@ export type BattleLossSummary = Message<'cityio.entity.v1.BattleLossSummary'> & * Describes the message cityio.entity.v1.BattleLossSummary. * Use `create(BattleLossSummarySchema)` to create a new message. */ -export const BattleLossSummarySchema: GenMessage = /*@__PURE__*/ messageDesc(file_cityio_entity_v1_battle, 0); +export const BattleLossSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cityio_entity_v1_battle, 0); /** * @generated from message cityio.entity.v1.BattleSide */ -export type BattleSide = Message<'cityio.entity.v1.BattleSide'> & { +export type BattleSide = Message<"cityio.entity.v1.BattleSide"> & { /** * @generated from field: repeated cityio.entity.v1.UserId user_ids = 1; */ @@ -103,13 +100,21 @@ export type BattleSide = Message<'cityio.entity.v1.BattleSide'> & { * @generated from field: cityio.entity.v1.BattleLossSummary last_round_losses = 10; */ lastRoundLosses?: BattleLossSummary | undefined; + + /** + * Effective durability added by a settlement center and/or owned fort. + * + * @generated from field: int32 defense_bonus_percent = 11; + */ + defenseBonusPercent: number; }; /** * Describes the message cityio.entity.v1.BattleSide. * Use `create(BattleSideSchema)` to create a new message. */ -export const BattleSideSchema: GenMessage = /*@__PURE__*/ messageDesc(file_cityio_entity_v1_battle, 1); +export const BattleSideSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cityio_entity_v1_battle, 1); /** * Battle exists only while combat is active. Each side can contain armies @@ -118,7 +123,7 @@ export const BattleSideSchema: GenMessage = /*@__PURE__*/ messageDes * * @generated from message cityio.entity.v1.Battle */ -export type Battle = Message<'cityio.entity.v1.Battle'> & { +export type Battle = Message<"cityio.entity.v1.Battle"> & { /** * @generated from field: cityio.entity.v1.BattleId battle_id = 1; */ @@ -159,4 +164,5 @@ export type Battle = Message<'cityio.entity.v1.Battle'> & { * Describes the message cityio.entity.v1.Battle. * Use `create(BattleSchema)` to create a new message. */ -export const BattleSchema: GenMessage = /*@__PURE__*/ messageDesc(file_cityio_entity_v1_battle, 2); +export const BattleSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cityio_entity_v1_battle, 2); diff --git a/src/lib/gen/cityio/entity/v1/building_pb.ts b/src/lib/gen/cityio/entity/v1/building_pb.ts index da411cb..eb41684 100644 --- a/src/lib/gen/cityio/entity/v1/building_pb.ts +++ b/src/lib/gen/cityio/entity/v1/building_pb.ts @@ -6,7 +6,7 @@ import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; import type { BuildingType, Coordinates } from "./common_pb"; import { file_cityio_entity_v1_common } from "./common_pb"; -import type { BuildingId, CityId } from "./ids_pb"; +import type { BuildingId, CityId, UserId } from "./ids_pb"; import { file_cityio_entity_v1_ids } from "./ids_pb"; import type { Timestamp } from "@bufbuild/protobuf/wkt"; import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; @@ -16,10 +16,10 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file cityio/entity/v1/building.proto. */ export const file_cityio_entity_v1_building: GenFile = /*@__PURE__*/ - fileDesc("Ch9jaXR5aW8vZW50aXR5L3YxL2J1aWxkaW5nLnByb3RvEhBjaXR5aW8uZW50aXR5LnYxIo4DCghCdWlsZGluZxIxCgtidWlsZGluZ19pZBgBIAEoCzIcLmNpdHlpby5lbnRpdHkudjEuQnVpbGRpbmdJZBIpCgdjaXR5X2lkGAIgASgLMhguY2l0eWlvLmVudGl0eS52MS5DaXR5SWQSLAoEdHlwZRgDIAEoDjIeLmNpdHlpby5lbnRpdHkudjEuQnVpbGRpbmdUeXBlEg0KBWxldmVsGAQgASgFEhQKDHRhcmdldF9sZXZlbBgFIAEoBRItCgZjb29yZHMYBiABKAsyHS5jaXR5aW8uZW50aXR5LnYxLkNvb3JkaW5hdGVzEjsKEmNvbnN0cnVjdGlvbl9zdGFydBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAIgBARI5ChBjb25zdHJ1Y3Rpb25fZW5kGAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEgBiAEBQhUKE19jb25zdHJ1Y3Rpb25fc3RhcnRCEwoRX2NvbnN0cnVjdGlvbl9lbmRiBnByb3RvMw", [file_cityio_entity_v1_common, file_cityio_entity_v1_ids, file_google_protobuf_timestamp]); + fileDesc("Ch9jaXR5aW8vZW50aXR5L3YxL2J1aWxkaW5nLnByb3RvEhBjaXR5aW8uZW50aXR5LnYxIsYDCghCdWlsZGluZxIxCgtidWlsZGluZ19pZBgBIAEoCzIcLmNpdHlpby5lbnRpdHkudjEuQnVpbGRpbmdJZBIpCgdjaXR5X2lkGAIgASgLMhguY2l0eWlvLmVudGl0eS52MS5DaXR5SWQSLAoEdHlwZRgDIAEoDjIeLmNpdHlpby5lbnRpdHkudjEuQnVpbGRpbmdUeXBlEg0KBWxldmVsGAQgASgFEhQKDHRhcmdldF9sZXZlbBgFIAEoBRItCgZjb29yZHMYBiABKAsyHS5jaXR5aW8uZW50aXR5LnYxLkNvb3JkaW5hdGVzEjsKEmNvbnN0cnVjdGlvbl9zdGFydBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAIgBARI5ChBjb25zdHJ1Y3Rpb25fZW5kGAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEgBiAEBEiwKBW93bmVyGAkgASgLMhguY2l0eWlvLmVudGl0eS52MS5Vc2VySWRIAogBAUIVChNfY29uc3RydWN0aW9uX3N0YXJ0QhMKEV9jb25zdHJ1Y3Rpb25fZW5kQggKBl9vd25lcmIGcHJvdG8z", [file_cityio_entity_v1_common, file_cityio_entity_v1_ids, file_google_protobuf_timestamp]); /** - * Building is a structure within a city. + * Building is either part of a city or a standalone player-owned structure. * * @generated from message cityio.entity.v1.Building */ @@ -63,6 +63,14 @@ export type Building = Message<"cityio.entity.v1.Building"> & { * @generated from field: optional google.protobuf.Timestamp construction_end = 8; */ constructionEnd?: Timestamp | undefined; + + /** + * Set only for standalone structures. City buildings inherit ownership from + * their city and continue to use city_id. + * + * @generated from field: optional cityio.entity.v1.UserId owner = 9; + */ + owner?: UserId | undefined; }; /** diff --git a/src/lib/gen/cityio/entity/v1/common_pb.ts b/src/lib/gen/cityio/entity/v1/common_pb.ts index e0dc89b..d060507 100644 --- a/src/lib/gen/cityio/entity/v1/common_pb.ts +++ b/src/lib/gen/cityio/entity/v1/common_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file cityio/entity/v1/common.proto. */ export const file_cityio_entity_v1_common: GenFile = /*@__PURE__*/ - fileDesc("Ch1jaXR5aW8vZW50aXR5L3YxL2NvbW1vbi5wcm90bxIQY2l0eWlvLmVudGl0eS52MSIjCgtDb29yZGluYXRlcxIJCgF4GAEgASgFEgkKAXkYAiABKAUiJAoEUmF0ZRINCgV2YWx1ZRgBIAEoAxINCgVzY2FsZRgCIAEoBSpNCghDaXR5VHlwZRIZChVDSVRZX1RZUEVfVU5TUEVDSUZJRUQQABISCg5DSVRZX1RZUEVfQ0lUWRABEhIKDkNJVFlfVFlQRV9UT1dOEAIq0AEKDEJ1aWxkaW5nVHlwZRIdChlCVUlMRElOR19UWVBFX1VOU1BFQ0lGSUVEEAASHQoZQlVJTERJTkdfVFlQRV9DSVRZX0NFTlRFUhABEh0KGUJVSUxESU5HX1RZUEVfVE9XTl9DRU5URVIQAhIaChZCVUlMRElOR19UWVBFX0JBUlJBQ0tTEAMSFwoTQlVJTERJTkdfVFlQRV9IT1VTRRAEEhYKEkJVSUxESU5HX1RZUEVfRkFSTRAFEhYKEkJVSUxESU5HX1RZUEVfTUlORRAGKogBCglUcm9vcFR5cGUSGgoWVFJPT1BfVFlQRV9VTlNQRUNJRklFRBAAEhYKElRST09QX1RZUEVfU09MRElFUhABEhUKEVRST09QX1RZUEVfQVJDSEVSEAISFgoSVFJPT1BfVFlQRV9DQVZBTFJZEAMSGAoUVFJPT1BfVFlQRV9BUlRJTExFUlkQBGIGcHJvdG8z"); + fileDesc("Ch1jaXR5aW8vZW50aXR5L3YxL2NvbW1vbi5wcm90bxIQY2l0eWlvLmVudGl0eS52MSIjCgtDb29yZGluYXRlcxIJCgF4GAEgASgFEgkKAXkYAiABKAUiJAoEUmF0ZRINCgV2YWx1ZRgBIAEoAxINCgVzY2FsZRgCIAEoBSpNCghDaXR5VHlwZRIZChVDSVRZX1RZUEVfVU5TUEVDSUZJRUQQABISCg5DSVRZX1RZUEVfQ0lUWRABEhIKDkNJVFlfVFlQRV9UT1dOEAIqhgIKDEJ1aWxkaW5nVHlwZRIdChlCVUlMRElOR19UWVBFX1VOU1BFQ0lGSUVEEAASHQoZQlVJTERJTkdfVFlQRV9DSVRZX0NFTlRFUhABEh0KGUJVSUxESU5HX1RZUEVfVE9XTl9DRU5URVIQAhIaChZCVUlMRElOR19UWVBFX0JBUlJBQ0tTEAMSFwoTQlVJTERJTkdfVFlQRV9IT1VTRRAEEhYKEkJVSUxESU5HX1RZUEVfRkFSTRAFEhYKEkJVSUxESU5HX1RZUEVfTUlORRAGEhwKGEJVSUxESU5HX1RZUEVfV0FUQ0hUT1dFUhAHEhYKEkJVSUxESU5HX1RZUEVfRk9SVBAIKogBCglUcm9vcFR5cGUSGgoWVFJPT1BfVFlQRV9VTlNQRUNJRklFRBAAEhYKElRST09QX1RZUEVfU09MRElFUhABEhUKEVRST09QX1RZUEVfQVJDSEVSEAISFgoSVFJPT1BfVFlQRV9DQVZBTFJZEAMSGAoUVFJPT1BfVFlQRV9BUlRJTExFUlkQBGIGcHJvdG8z"); /** * Coordinates is a position on the game map. @@ -130,6 +130,16 @@ export enum BuildingType { * @generated from enum value: BUILDING_TYPE_MINE = 6; */ MINE = 6, + + /** + * @generated from enum value: BUILDING_TYPE_WATCHTOWER = 7; + */ + WATCHTOWER = 7, + + /** + * @generated from enum value: BUILDING_TYPE_FORT = 8; + */ + FORT = 8, } /** diff --git a/src/lib/gen/cityio/service/v1/building_pb.ts b/src/lib/gen/cityio/service/v1/building_pb.ts index 5cf3c18..6c6c737 100644 --- a/src/lib/gen/cityio/service/v1/building_pb.ts +++ b/src/lib/gen/cityio/service/v1/building_pb.ts @@ -23,6 +23,8 @@ export const file_cityio_service_v1_building: GenFile = /*@__PURE__*/ */ export type CreateBuildingRequest = Message<"cityio.service.v1.CreateBuildingRequest"> & { /** + * Required for city buildings and omitted for standalone watchtowers/forts. + * * @generated from field: cityio.entity.v1.CityId city_id = 1; */ cityId?: CityId | undefined; @@ -161,6 +163,8 @@ export const DeleteBuildingResponseSchema: GenMessage = */ export type ListBuildingsRequest = Message<"cityio.service.v1.ListBuildingsRequest"> & { /** + * When omitted, returns the caller's standalone structures. + * * @generated from field: cityio.entity.v1.CityId city_id = 1; */ cityId?: CityId | undefined; diff --git a/src/lib/gen/cityio/service/v1/config_pb.ts b/src/lib/gen/cityio/service/v1/config_pb.ts index cb36658..f1505aa 100644 --- a/src/lib/gen/cityio/service/v1/config_pb.ts +++ b/src/lib/gen/cityio/service/v1/config_pb.ts @@ -2,30 +2,26 @@ // @generated from file cityio/service/v1/config.proto (package cityio.service.v1, syntax proto3) /* eslint-disable */ -import type { GenFile, GenMessage, GenService } from '@bufbuild/protobuf/codegenv2'; -import { fileDesc, messageDesc, serviceDesc } from '@bufbuild/protobuf/codegenv2'; -import type { BuildingType, Rate } from '../../entity/v1/common_pb'; -import { file_cityio_entity_v1_common } from '../../entity/v1/common_pb'; -import type { Duration } from '@bufbuild/protobuf/wkt'; -import { file_google_protobuf_duration } from '@bufbuild/protobuf/wkt'; -import type { Message } from '@bufbuild/protobuf'; +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { BuildingType, Rate } from "../../entity/v1/common_pb"; +import { file_cityio_entity_v1_common } from "../../entity/v1/common_pb"; +import type { Duration } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_duration } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; /** * Describes the file cityio/service/v1/config.proto. */ -export const file_cityio_service_v1_config: GenFile = - /*@__PURE__*/ - fileDesc( - 'Ch5jaXR5aW8vc2VydmljZS92MS9jb25maWcucHJvdG8SEWNpdHlpby5zZXJ2aWNlLnYxIjIKDlJlc291cmNlQW1vdW50EhAKCHJlc291cmNlGAEgASgJEg4KBmFtb3VudBgCIAEoAyJGCgxSZXNvdXJjZVJhdGUSEAoIcmVzb3VyY2UYASABKAkSJAoEcmF0ZRgCIAEoCzIWLmNpdHlpby5lbnRpdHkudjEuUmF0ZSL2AQoSQnVpbGRpbmdMZXZlbFN0YXRzEg0KBWxldmVsGAEgASgFEi8KBGNvc3QYAiADKAsyIS5jaXR5aW8uc2VydmljZS52MS5SZXNvdXJjZUFtb3VudBI0ChFjb25zdHJ1Y3Rpb25fdGltZRgDIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbhIzCgpwcm9kdWN0aW9uGAQgAygLMh8uY2l0eWlvLnNlcnZpY2UudjEuUmVzb3VyY2VSYXRlEhIKCnBvcHVsYXRpb24YBSABKAESIQoZdHJhaW5pbmdfc3BlZWRfbXVsdGlwbGllchgGIAEoASJ1Cg5CdWlsZGluZ0NvbmZpZxIsCgR0eXBlGAEgASgOMh4uY2l0eWlvLmVudGl0eS52MS5CdWlsZGluZ1R5cGUSNQoGbGV2ZWxzGAIgAygLMiUuY2l0eWlvLnNlcnZpY2UudjEuQnVpbGRpbmdMZXZlbFN0YXRzIhYKFEdldEdhbWVDb25maWdSZXF1ZXN0IvICChZQb3B1bGF0aW9uUG9saWN5Q29uZmlnEh0KFWNvcmVfY2l2aWxpYW5fcGVyY2VudBgBIAEoBRIfChdkZWZhdWx0X21pbGl0aWFfcGVyY2VudBgCIAEoBRIfChduZXV0cmFsX21pbGl0aWFfcGVyY2VudBgDIAEoBRIbChNtYXhfbWlsaXRpYV9wZXJjZW50GAQgASgFEhwKFG1heF9taWxpdGFyeV9wZXJjZW50GAUgASgFEiAKGGRlZmF1bHRfdGF4X3JhdGVfcGVyY2VudBgGIAEoBRIcChRtYXhfdGF4X3JhdGVfcGVyY2VudBgHIAEoBRI3Chd0YXhfZ29sZF9wZXJfcG9wdWxhdGlvbhgIIAEoCzIWLmNpdHlpby5lbnRpdHkudjEuUmF0ZRIbChNtaW5fbWlsaXRpYV9wZXJjZW50GAkgASgFEiYKHm1heF90YXhfZ3Jvd3RoX3BlbmFsdHlfcGVyY2VudBgKIAEoBSKvAgoVR2V0R2FtZUNvbmZpZ1Jlc3BvbnNlEhAKCG1hcF9zaXplGAEgASgFEhEKCWNpdHlfc2l6ZRgCIAEoBRIVCg12aXNpb25fcmFkaXVzGAMgASgFEjAKDWJ1aWxkaW5nX3RpY2sYBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb24SNAoJYnVpbGRpbmdzGAUgAygLMiEuY2l0eWlvLnNlcnZpY2UudjEuQnVpbGRpbmdDb25maWcSLAoJY2l0eV90aWNrGAYgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEkQKEXBvcHVsYXRpb25fcG9saWN5GAcgASgLMikuY2l0eWlvLnNlcnZpY2UudjEuUG9wdWxhdGlvblBvbGljeUNvbmZpZzJzCg1Db25maWdTZXJ2aWNlEmIKDUdldEdhbWVDb25maWcSJy5jaXR5aW8uc2VydmljZS52MS5HZXRHYW1lQ29uZmlnUmVxdWVzdBooLmNpdHlpby5zZXJ2aWNlLnYxLkdldEdhbWVDb25maWdSZXNwb25zZWIGcHJvdG8z', - [file_cityio_entity_v1_common, file_google_protobuf_duration] - ); +export const file_cityio_service_v1_config: GenFile = /*@__PURE__*/ + fileDesc("Ch5jaXR5aW8vc2VydmljZS92MS9jb25maWcucHJvdG8SEWNpdHlpby5zZXJ2aWNlLnYxIjIKDlJlc291cmNlQW1vdW50EhAKCHJlc291cmNlGAEgASgJEg4KBmFtb3VudBgCIAEoAyJGCgxSZXNvdXJjZVJhdGUSEAoIcmVzb3VyY2UYASABKAkSJAoEcmF0ZRgCIAEoCzIWLmNpdHlpby5lbnRpdHkudjEuUmF0ZSKsAgoSQnVpbGRpbmdMZXZlbFN0YXRzEg0KBWxldmVsGAEgASgFEi8KBGNvc3QYAiADKAsyIS5jaXR5aW8uc2VydmljZS52MS5SZXNvdXJjZUFtb3VudBI0ChFjb25zdHJ1Y3Rpb25fdGltZRgDIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbhIzCgpwcm9kdWN0aW9uGAQgAygLMh8uY2l0eWlvLnNlcnZpY2UudjEuUmVzb3VyY2VSYXRlEhIKCnBvcHVsYXRpb24YBSABKAESIQoZdHJhaW5pbmdfc3BlZWRfbXVsdGlwbGllchgGIAEoARIVCg12aXNpb25fcmFkaXVzGAcgASgFEh0KFWRlZmVuc2VfYm9udXNfcGVyY2VudBgIIAEoBSJ1Cg5CdWlsZGluZ0NvbmZpZxIsCgR0eXBlGAEgASgOMh4uY2l0eWlvLmVudGl0eS52MS5CdWlsZGluZ1R5cGUSNQoGbGV2ZWxzGAIgAygLMiUuY2l0eWlvLnNlcnZpY2UudjEuQnVpbGRpbmdMZXZlbFN0YXRzIhYKFEdldEdhbWVDb25maWdSZXF1ZXN0IvICChZQb3B1bGF0aW9uUG9saWN5Q29uZmlnEh0KFWNvcmVfY2l2aWxpYW5fcGVyY2VudBgBIAEoBRIfChdkZWZhdWx0X21pbGl0aWFfcGVyY2VudBgCIAEoBRIfChduZXV0cmFsX21pbGl0aWFfcGVyY2VudBgDIAEoBRIbChNtYXhfbWlsaXRpYV9wZXJjZW50GAQgASgFEhwKFG1heF9taWxpdGFyeV9wZXJjZW50GAUgASgFEiAKGGRlZmF1bHRfdGF4X3JhdGVfcGVyY2VudBgGIAEoBRIcChRtYXhfdGF4X3JhdGVfcGVyY2VudBgHIAEoBRI3Chd0YXhfZ29sZF9wZXJfcG9wdWxhdGlvbhgIIAEoCzIWLmNpdHlpby5lbnRpdHkudjEuUmF0ZRIbChNtaW5fbWlsaXRpYV9wZXJjZW50GAkgASgFEiYKHm1heF90YXhfZ3Jvd3RoX3BlbmFsdHlfcGVyY2VudBgKIAEoBSLTAgoVR2V0R2FtZUNvbmZpZ1Jlc3BvbnNlEhAKCG1hcF9zaXplGAEgASgFEhEKCWNpdHlfc2l6ZRgCIAEoBRIVCg12aXNpb25fcmFkaXVzGAMgASgFEjAKDWJ1aWxkaW5nX3RpY2sYBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb24SNAoJYnVpbGRpbmdzGAUgAygLMiEuY2l0eWlvLnNlcnZpY2UudjEuQnVpbGRpbmdDb25maWcSLAoJY2l0eV90aWNrGAYgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEkQKEXBvcHVsYXRpb25fcG9saWN5GAcgASgLMikuY2l0eWlvLnNlcnZpY2UudjEuUG9wdWxhdGlvblBvbGljeUNvbmZpZxIiChpzdHJ1Y3R1cmVfcGxhY2VtZW50X3JhZGl1cxgIIAEoBTJzCg1Db25maWdTZXJ2aWNlEmIKDUdldEdhbWVDb25maWcSJy5jaXR5aW8uc2VydmljZS52MS5HZXRHYW1lQ29uZmlnUmVxdWVzdBooLmNpdHlpby5zZXJ2aWNlLnYxLkdldEdhbWVDb25maWdSZXNwb25zZWIGcHJvdG8z", [file_cityio_entity_v1_common, file_google_protobuf_duration]); /** * ResourceAmount is a one-shot amount (e.g. a build cost). * * @generated from message cityio.service.v1.ResourceAmount */ -export type ResourceAmount = Message<'cityio.service.v1.ResourceAmount'> & { +export type ResourceAmount = Message<"cityio.service.v1.ResourceAmount"> & { /** * @generated from field: string resource = 1; */ @@ -41,14 +37,15 @@ export type ResourceAmount = Message<'cityio.service.v1.ResourceAmount'> & { * Describes the message cityio.service.v1.ResourceAmount. * Use `create(ResourceAmountSchema)` to create a new message. */ -export const ResourceAmountSchema: GenMessage = /*@__PURE__*/ messageDesc(file_cityio_service_v1_config, 0); +export const ResourceAmountSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cityio_service_v1_config, 0); /** * ResourceRate is an ongoing flow (e.g. production per hour). * * @generated from message cityio.service.v1.ResourceRate */ -export type ResourceRate = Message<'cityio.service.v1.ResourceRate'> & { +export type ResourceRate = Message<"cityio.service.v1.ResourceRate"> & { /** * @generated from field: string resource = 1; */ @@ -64,12 +61,13 @@ export type ResourceRate = Message<'cityio.service.v1.ResourceRate'> & { * Describes the message cityio.service.v1.ResourceRate. * Use `create(ResourceRateSchema)` to create a new message. */ -export const ResourceRateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_cityio_service_v1_config, 1); +export const ResourceRateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cityio_service_v1_config, 1); /** * @generated from message cityio.service.v1.BuildingLevelStats */ -export type BuildingLevelStats = Message<'cityio.service.v1.BuildingLevelStats'> & { +export type BuildingLevelStats = Message<"cityio.service.v1.BuildingLevelStats"> & { /** * @generated from field: int32 level = 1; */ @@ -101,18 +99,33 @@ export type BuildingLevelStats = Message<'cityio.service.v1.BuildingLevelStats'> * @generated from field: double training_speed_multiplier = 6; */ trainingSpeedMultiplier: number; + + /** + * Watchtower only: Chebyshev map-vision radius at this completed level. + * + * @generated from field: int32 vision_radius = 7; + */ + visionRadius: number; + + /** + * Centers and forts: effective durability added during eligible battles. + * + * @generated from field: int32 defense_bonus_percent = 8; + */ + defenseBonusPercent: number; }; /** * Describes the message cityio.service.v1.BuildingLevelStats. * Use `create(BuildingLevelStatsSchema)` to create a new message. */ -export const BuildingLevelStatsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_cityio_service_v1_config, 2); +export const BuildingLevelStatsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cityio_service_v1_config, 2); /** * @generated from message cityio.service.v1.BuildingConfig */ -export type BuildingConfig = Message<'cityio.service.v1.BuildingConfig'> & { +export type BuildingConfig = Message<"cityio.service.v1.BuildingConfig"> & { /** * @generated from field: cityio.entity.v1.BuildingType type = 1; */ @@ -128,23 +141,26 @@ export type BuildingConfig = Message<'cityio.service.v1.BuildingConfig'> & { * Describes the message cityio.service.v1.BuildingConfig. * Use `create(BuildingConfigSchema)` to create a new message. */ -export const BuildingConfigSchema: GenMessage = /*@__PURE__*/ messageDesc(file_cityio_service_v1_config, 3); +export const BuildingConfigSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cityio_service_v1_config, 3); /** * @generated from message cityio.service.v1.GetGameConfigRequest */ -export type GetGameConfigRequest = Message<'cityio.service.v1.GetGameConfigRequest'> & {}; +export type GetGameConfigRequest = Message<"cityio.service.v1.GetGameConfigRequest"> & { +}; /** * Describes the message cityio.service.v1.GetGameConfigRequest. * Use `create(GetGameConfigRequestSchema)` to create a new message. */ -export const GetGameConfigRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_cityio_service_v1_config, 4); +export const GetGameConfigRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cityio_service_v1_config, 4); /** * @generated from message cityio.service.v1.PopulationPolicyConfig */ -export type PopulationPolicyConfig = Message<'cityio.service.v1.PopulationPolicyConfig'> & { +export type PopulationPolicyConfig = Message<"cityio.service.v1.PopulationPolicyConfig"> & { /** * @generated from field: int32 core_civilian_percent = 1; */ @@ -205,12 +221,13 @@ export type PopulationPolicyConfig = Message<'cityio.service.v1.PopulationPolicy * Describes the message cityio.service.v1.PopulationPolicyConfig. * Use `create(PopulationPolicyConfigSchema)` to create a new message. */ -export const PopulationPolicyConfigSchema: GenMessage = /*@__PURE__*/ messageDesc(file_cityio_service_v1_config, 5); +export const PopulationPolicyConfigSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cityio_service_v1_config, 5); /** * @generated from message cityio.service.v1.GetGameConfigResponse */ -export type GetGameConfigResponse = Message<'cityio.service.v1.GetGameConfigResponse'> & { +export type GetGameConfigResponse = Message<"cityio.service.v1.GetGameConfigResponse"> & { /** * @generated from field: int32 map_size = 1; */ @@ -245,13 +262,19 @@ export type GetGameConfigResponse = Message<'cityio.service.v1.GetGameConfigResp * @generated from field: cityio.service.v1.PopulationPolicyConfig population_policy = 7; */ populationPolicy?: PopulationPolicyConfig | undefined; + + /** + * @generated from field: int32 structure_placement_radius = 8; + */ + structurePlacementRadius: number; }; /** * Describes the message cityio.service.v1.GetGameConfigResponse. * Use `create(GetGameConfigResponseSchema)` to create a new message. */ -export const GetGameConfigResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_cityio_service_v1_config, 6); +export const GetGameConfigResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cityio_service_v1_config, 6); /** * @generated from service cityio.service.v1.ConfigService @@ -261,8 +284,9 @@ export const ConfigService: GenService<{ * @generated from rpc cityio.service.v1.ConfigService.GetGameConfig */ getGameConfig: { - methodKind: 'unary'; + methodKind: "unary"; input: typeof GetGameConfigRequestSchema; output: typeof GetGameConfigResponseSchema; - }; -}> = /*@__PURE__*/ serviceDesc(file_cityio_service_v1_config, 0); + }, +}> = /*@__PURE__*/ + serviceDesc(file_cityio_service_v1_config, 0); diff --git a/src/lib/stores.ts b/src/lib/stores.ts index fd762ad..9773647 100644 --- a/src/lib/stores.ts +++ b/src/lib/stores.ts @@ -40,11 +40,12 @@ export const gameConfig = writable<{ mapSize: number; citySize: number; visionRadius: number; + structurePlacementRadius: number; buildingTick?: Duration; cityTick?: Duration; buildings: BuildingConfig[]; populationPolicy?: PopulationPolicyConfig; -}>({ mapSize: 75, citySize: 5, visionRadius: 5, buildings: [] }); +}>({ mapSize: 75, citySize: 5, visionRadius: 3, structurePlacementRadius: 10, buildings: [] }); export const capital = writable(null); export const mapCenter = writable<{ x: number; y: number }>({ x: 0, y: 0 }); diff --git a/src/routes/game/+page.svelte b/src/routes/game/+page.svelte index d099f7e..332c4fe 100644 --- a/src/routes/game/+page.svelte +++ b/src/routes/game/+page.svelte @@ -111,7 +111,8 @@ // ── UI state ──────────────────────────────────────────── let sel: { x: number; y: number; tile?: Tile; city?: City; building?: Building; armies?: Army[] } | null = null; let buildType: BuildingType = BuildingType.HOUSE; - const placeTypes = [BuildingType.HOUSE, BuildingType.FARM, BuildingType.MINE, BuildingType.BARRACKS]; + const cityPlaceTypes = [BuildingType.HOUSE, BuildingType.FARM, BuildingType.MINE, BuildingType.BARRACKS]; + const standalonePlaceTypes = [BuildingType.WATCHTOWER, BuildingType.FORT]; let busy = false; let err = ''; let notice = ''; @@ -234,6 +235,12 @@ showCityManagement = true; }; + const openBuildPanel = (standalone: boolean) => { + if (standalone && !standalonePlaceTypes.includes(buildType)) buildType = BuildingType.WATCHTOWER; + if (!standalone && standalonePlaceTypes.includes(buildType)) buildType = BuildingType.HOUSE; + showBuild = true; + }; + // Keyboard navigation let showHelp = false; let cityCycleIdx = -1; @@ -299,6 +306,8 @@ if (type === BuildingType.FARM) return 'farm'; if (type === BuildingType.MINE) return 'mine'; if (type === BuildingType.BARRACKS) return 'barracks'; + if (type === BuildingType.WATCHTOWER) return 'watchtower'; + if (type === BuildingType.FORT) return 'fort'; if (type === BuildingType.CITY_CENTER) return 'city_center'; if (type === BuildingType.TOWN_CENTER) return 'town_center'; return 'house'; @@ -326,9 +335,29 @@ [BuildingType.BARRACKS]: 'Barracks', [BuildingType.HOUSE]: 'House', [BuildingType.FARM]: 'Farm', - [BuildingType.MINE]: 'Mine' + [BuildingType.MINE]: 'Mine', + [BuildingType.WATCHTOWER]: 'Watchtower', + [BuildingType.FORT]: 'Fort' }; const bName = (t: BuildingType) => BN[t] ?? 'Unknown'; + const buildingOwnerId = (building?: Building) => { + if (!building) return undefined; + if (building.owner?.value) return building.owner.value; + const cityId = building.cityId?.value; + return cityId ? $cities.find((city) => city.cityId?.value === cityId)?.owner?.value : undefined; + }; + const buildingOwned = (building?: Building) => buildingOwnerId(building) === $userId; + const distanceToSettlement = (city: City, x: number, y: number) => { + if (!city.start) return Number.POSITIVE_INFINITY; + const dx = Math.max(0, city.start.x - x, x - (city.start.x + city.size - 1)); + const dy = Math.max(0, city.start.y - y, y - (city.start.y + city.size - 1)); + return Math.max(dx, dy); + }; + const canPlaceStandaloneStructure = (selection: typeof sel) => { + if (!selection?.tile || selection.city || selection.building || selection.armies?.length || terrainAt(selection.x, selection.y) === TerrainType.WATER) return false; + const radius = $gameConfig.structurePlacementRadius || 10; + return $cities.some((city) => city.owner?.value === $userId && distanceToSettlement(city, selection.x, selection.y) <= radius); + }; const cName = (t: CityType) => (t === CityType.CITY ? 'City' : t === CityType.TOWN ? 'Town' : 'Settlement'); const barracksTrainingSpeed = (building: Building) => getLevelStats(BuildingType.BARRACKS, building.level)?.trainingSpeedMultiplier || 1; const residents = (city: City) => Math.max(0, Math.floor(city.population)); @@ -2482,7 +2511,10 @@ const t = tileData.get(clickedKey); const doubleClicked = lastTileClickKey === clickedKey && clickedAt - lastTileClickAt <= DOUBLE_CLICK_MS; const openBuildingManagement = !!t?.building && doubleClicked; - const openConstruction = !t?.building && !t?.armies?.length && t?.city?.owner?.value === $userId && doubleClicked; + const selection = { x: mc.x, y: mc.y, ...t }; + const openCityConstruction = !t?.building && !t?.armies?.length && t?.city?.owner?.value === $userId; + const openStandaloneConstruction = canPlaceStandaloneStructure(selection); + const openConstruction = doubleClicked && (openCityConstruction || openStandaloneConstruction); lastTileClickKey = clickedKey; lastTileClickAt = clickedAt; if (t?.armies?.length === 1 && !t.building) { @@ -2490,10 +2522,12 @@ } else { trackedArmyId = null; selectedArmyId = null; - sel = { x: mc.x, y: mc.y, ...t }; + sel = selection; err = ''; notice = ''; showBuild = openConstruction; + if (openStandaloneConstruction) buildType = BuildingType.WATCHTOWER; + else if (openCityConstruction && standalonePlaceTypes.includes(buildType)) buildType = BuildingType.HOUSE; showCityManagement = false; recruitCount = 1; drawSel(mc.x, mc.y); @@ -3100,6 +3134,12 @@ {:else if type === BuildingType.BARRACKS} + {:else if type === BuildingType.WATCHTOWER} + + + {:else if type === BuildingType.FORT} + + {:else} @@ -3126,7 +3166,12 @@ {(side?.userIds.length ?? 0) === 1 ? 'commander' : 'commanders'} - {#if yourSide}Your side{/if} +
+ {#if (side?.defenseBonusPercent ?? 0) > 0}+{side?.defenseBonusPercent}% defense{/if} + {#if yourSide}Your side{/if} +
@@ -3826,7 +3871,7 @@ - {sel.city?.owner?.value === $userId ? `Manage ${bName(sel.building.type)}` : 'View details'} + {buildingOwned(sel.building) ? `Manage ${bName(sel.building.type)}` : 'View details'} {/if}
@@ -4309,7 +4354,9 @@ {@render structureGlyph(sel.building.type, sel.building.level)}
{bName(sel.building.type)} - {isBuilding ? 'Work underway' : `Level ${sel.building.level} city building`} + {isBuilding ? 'Work underway' : `Level ${sel.building.level} ${sel.building.owner ? 'standalone structure' : 'city building'}`}
{#if sel.building.constructionStart && sel.building.constructionEnd} @@ -4348,9 +4395,15 @@ {#if isBarracks}
Training lane{stats.trainingSpeedMultiplier.toFixed(2)}× speed
{/if} + {#if stats.visionRadius > 0} +
Vision radius{stats.visionRadius} tiles
+ {/if} + {#if stats.defenseBonusPercent > 0} +
Defense+{stats.defenseBonusPercent}% durability
+ {/if} {/if} - {#if nextStats && sel.city?.owner?.value === $userId} + {#if nextStats && buildingOwned(sel.building)}
Next level
{#if barracksTrainingInProgress} @@ -4400,9 +4453,27 @@ >
{/if} + {#if nextStats.visionRadius > 0} +
+ Vision radius + {stats?.visionRadius ?? 0}{nextStats.visionRadius} tiles +
+ {/if} + {#if nextStats.defenseBonusPercent > 0} +
+ Defense + +{stats?.defenseBonusPercent ?? 0}%+{nextStats.defenseBonusPercent}% +
+ {/if} {/if} - {#if sel.city?.owner?.value === $userId} + {#if buildingOwned(sel.building)}
- {#each placeTypes as bt} + {#each placementTypes as bt}
{/if} + {#if buildStats.visionRadius > 0} +
Vision radius{buildStats.visionRadius} tiles
+ {/if} + {#if buildStats.defenseBonusPercent > 0} +
Defense+{buildStats.defenseBonusPercent}% durability
+ {/if} {/if} + {#if standalonePlacement} +
Must remain within {$gameConfig.structurePlacementRadius || 10} tiles of an owned settlement.
+ {/if}
{:else}
-
{/if} From 44a0af2dd11696117bc1190c2bab44bf9799f43f Mon Sep 17 00:00:00 2001 From: Prayuj Tuli Date: Tue, 25 Aug 2026 23:25:03 -0400 Subject: [PATCH 2/3] feat: toggle tile and army selection --- proto/cityio/service/v1/building.proto | 3 +- src/lib/gen/cityio/service/v1/building_pb.ts | 3 +- src/routes/game/+page.svelte | 98 ++++++++++++-------- 3 files changed, 64 insertions(+), 40 deletions(-) diff --git a/proto/cityio/service/v1/building.proto b/proto/cityio/service/v1/building.proto index 66f73df..f08aa13 100644 --- a/proto/cityio/service/v1/building.proto +++ b/proto/cityio/service/v1/building.proto @@ -7,7 +7,8 @@ import "cityio/entity/v1/common.proto"; import "cityio/entity/v1/ids.proto"; message CreateBuildingRequest { - // Required for city buildings and omitted for standalone watchtowers/forts. + // Required for city buildings and omitted for standalone watchtowers/forts, + // which require one of the caller's armies on the target tile. cityio.entity.v1.CityId city_id = 1; cityio.entity.v1.BuildingType type = 2; cityio.entity.v1.Coordinates coords = 3; diff --git a/src/lib/gen/cityio/service/v1/building_pb.ts b/src/lib/gen/cityio/service/v1/building_pb.ts index 6c6c737..2edbd23 100644 --- a/src/lib/gen/cityio/service/v1/building_pb.ts +++ b/src/lib/gen/cityio/service/v1/building_pb.ts @@ -23,7 +23,8 @@ export const file_cityio_service_v1_building: GenFile = /*@__PURE__*/ */ export type CreateBuildingRequest = Message<"cityio.service.v1.CreateBuildingRequest"> & { /** - * Required for city buildings and omitted for standalone watchtowers/forts. + * Required for city buildings and omitted for standalone watchtowers/forts, + * which require one of the caller's armies on the target tile. * * @generated from field: cityio.entity.v1.CityId city_id = 1; */ diff --git a/src/routes/game/+page.svelte b/src/routes/game/+page.svelte index 332c4fe..308466f 100644 --- a/src/routes/game/+page.svelte +++ b/src/routes/game/+page.svelte @@ -354,7 +354,8 @@ return Math.max(dx, dy); }; const canPlaceStandaloneStructure = (selection: typeof sel) => { - if (!selection?.tile || selection.city || selection.building || selection.armies?.length || terrainAt(selection.x, selection.y) === TerrainType.WATER) return false; + if (!selection?.tile || selection.city || selection.building || terrainAt(selection.x, selection.y) === TerrainType.WATER) return false; + if (!selection.armies?.some((army) => army.owner?.value === $userId)) return false; const radius = $gameConfig.structurePlacementRadius || 10; return $cities.some((city) => city.owner?.value === $userId && distanceToSettlement(city, selection.x, selection.y) <= radius); }; @@ -1475,6 +1476,24 @@ clearMoveTarget(); }; + const focusTile = (x: number, y: number, openBuild = false, openBuildingManagement = false) => { + cancelMoveMode(); + trackedArmyId = null; + selectedArmyId = null; + selectedBattleId = null; + sel = { x, y, ...tileData.get(tileKey(x, y)) }; + err = ''; + notice = ''; + showBuild = openBuild; + if (openBuild) openBuildPanel(!sel.city); + showCityManagement = false; + showBattlePanel = false; + recruitCount = 1; + drawSel(x, y); + if (openBuildingManagement) openSelectedManagement(); + scheduleRender(); + }; + const focusArmy = (army: Army, center = true) => { const id = army.armyId?.value; if (!id || !army.coords) return; @@ -1506,6 +1525,16 @@ scheduleRender(); }; + const toggleTileArmySelection = () => { + if (!sel?.armies?.length) return; + if (selectedArmy) { + focusTile(sel.x, sel.y); + return; + } + const army = sel.armies.find((candidate) => candidate.owner?.value === $userId) ?? sel.armies[0]; + focusArmy(army, false); + }; + const prepareMove = (army: Army) => { const id = army.armyId?.value; if (!id || !army.coords || army.owner?.value !== $userId) return; @@ -2190,18 +2219,11 @@ if (event.button !== 0) return; event.stopPropagation(); if (visibleArmies.length === 1) { - focusArmy(army, false); + if (selectedArmyId === army.armyId?.value && sel?.x === col && sel?.y === row) focusTile(col, row); + else focusArmy(army, false); return; } - cancelMoveMode(); - trackedArmyId = null; - selectedArmyId = null; - sel = { x: col, y: row, ...tileData.get(k) }; - err = ''; - notice = ''; - showBuild = false; - showCityManagement = false; - drawSel(col, row); + focusTile(col, row); }); tc.addChild(marker); } @@ -2518,20 +2540,10 @@ lastTileClickKey = clickedKey; lastTileClickAt = clickedAt; if (t?.armies?.length === 1 && !t.building) { - focusArmy(t.armies[0], false); + if (selectedArmyId === t.armies[0].armyId?.value && sel?.x === mc.x && sel?.y === mc.y) focusTile(mc.x, mc.y, openConstruction); + else focusArmy(t.armies[0], false); } else { - trackedArmyId = null; - selectedArmyId = null; - sel = selection; - err = ''; - notice = ''; - showBuild = openConstruction; - if (openStandaloneConstruction) buildType = BuildingType.WATCHTOWER; - else if (openCityConstruction && standalonePlaceTypes.includes(buildType)) buildType = BuildingType.HOUSE; - showCityManagement = false; - recruitCount = 1; - drawSel(mc.x, mc.y); - if (openBuildingManagement) openSelectedManagement(); + focusTile(mc.x, mc.y, openConstruction, openBuildingManagement); } } } else if (!easeMotion || performance.now() - lastMoveT > 80) { @@ -3699,7 +3711,7 @@
- {#if selectedArmy || sel.armies?.length} + {#if selectedArmy} {@render managementGlyph('armies')} {:else if sel.building}
- +
+ {#if sel.armies?.length && !showCityManagement} + + {/if} + +
{#if err} @@ -4678,7 +4700,7 @@ {/if} {#if standalonePlacement} -
Must remain within {$gameConfig.structurePlacementRadius || 10} tiles of an owned settlement.
+
Requires one of your armies here and must remain within {$gameConfig.structurePlacementRadius || 10} tiles of an owned settlement.
{/if}
From 3a83c80d06681502be956b54fe4ceb547268cf29 Mon Sep 17 00:00:00 2001 From: Prayuj Tuli Date: Wed, 26 Aug 2026 00:24:57 -0400 Subject: [PATCH 3/3] fix: prevent standalone structure inspector overlap --- src/routes/game/+page.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/game/+page.svelte b/src/routes/game/+page.svelte index 308466f..846bafa 100644 --- a/src/routes/game/+page.svelte +++ b/src/routes/game/+page.svelte @@ -3682,7 +3682,7 @@ class={showCityManagement && sel?.city && !selectedArmy ? 'pointer-events-auto absolute inset-0 z-10 flex items-center justify-center bg-black/40 p-3 sm:p-8' : `pointer-events-none absolute bottom-3 left-1/2 z-10 w-[calc(100vw-1.5rem)] max-w-[800px] -translate-x-1/2 sm:bottom-4 ${ - !selectedArmy && !sel?.armies?.length && !showBuild ? 'sm:max-w-[460px]' : '' + !selectedArmy && (!sel?.armies?.length || sel?.building) && !showBuild ? 'sm:max-w-[460px]' : '' } ${managementOpen ? 'lg:left-4 lg:right-[22rem] lg:mx-auto lg:w-[calc(100%-23rem)] lg:translate-x-0' : ''}`} on:pointerdown|self={() => { if (showCityManagement && sel?.city && !selectedArmy) showCityManagement = false; @@ -4303,7 +4303,7 @@ {/if} - {#if !selectedArmy && sel.armies?.length && !showCityManagement} + {#if !selectedArmy && !sel.building && sel.armies?.length && !showCityManagement} {@const stackCompositionExact = sel.armies.every((army) => army.compositionVisibility === ArmyCompositionVisibility.EXACT)} {@const friendlyArmies = sel.armies.filter((army) => army.owner?.value === $userId)}
@@ -4719,7 +4719,7 @@
{/if} - {:else if !selectedArmy && !sel.city && !sel.armies?.length} + {:else if !selectedArmy && !sel.city && !sel.building && !sel.armies?.length}
{selectedUnknown ? 'Beyond explored territory' : selectedVisibility === TileVisibilityState.EXPLORED ? 'Terrain remembered; current occupants are hidden' : 'No structures on this tile'}