From 552474a34bf9ddb0e3ff7271b663ac82a45bf6e9 Mon Sep 17 00:00:00 2001 From: Jason Jiang Date: Sun, 2 Aug 2026 23:08:51 -0400 Subject: [PATCH] feat: generate the world map on the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The world was a featureless 75x75 grid — no terrain, no biomes, no tiles table. Every tile the client drew was grass or fog, and settlements were scattered by Poisson-disk sampling with no idea what the ground underneath them was. Add internal/world: a dependency-free generator producing three orthogonal planes (ground, relief, vegetation) plus rivers and special resources, deterministic from constants.WorldSeed. Terrain is regenerated on every boot rather than persisted, so the map survives the reset that wipes everything else. MapService.GetTerrain ships the planes packed one byte per tile — a few kilobytes for the whole map, so it is neither paged nor filtered by vision. Three decisions worth recording: Planes stay orthogonal rather than collapsing into one biome enum. Collapsed, forest-on-tundra and forest-on-plains become separate values each needing their own art; kept apart, a feature composites over any ground. Thresholds are quantiles of the elevation field rather than fixed cutoffs, so retuning the noise cannot accidentally flood or drown the world — land is exactly landFraction whatever the octaves do. Noise is sampled at hexPoint(), not at (col, row). Columns sit 1.5 apart and rows only ~0.866, so sampling in grid space stretches every coastline and mountain range by about 1.73x horizontally once drawn. Placement is now terrain-aware. canPlace rejects footprints on water, peaks and ice, which halved the towns surviving seeding, so townMinSpacing drops 5 -> 4 to hold density roughly where it was. FindEmptyCityBlock no longer draws random empty blocks. Towns are seeded first and take the good land, so whatever the query leaves free is disproportionately the water and mountain the seeder rejected — players drawn that way reliably land inside a mountain range. It now scans the map in memory, scores every candidate on fertility, fresh water and coastline, and picks among the best 15% so consecutive registrations do not share a tile. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/main.go | 13 +- internal/constants/constants.go | 6 + internal/gen/cityio/entity/v1/terrain.pb.go | 376 ++++++++++ internal/gen/cityio/service/v1/map.pb.go | 214 +++++- .../v1/servicev1connect/map.connect.go | 32 +- internal/persistence/store.go | 55 +- internal/rpc/map.go | 25 + internal/rpc/rpc.go | 6 +- internal/setup/setup.go | 39 +- internal/world/hex.go | 44 ++ internal/world/noise.go | 142 ++++ internal/world/placement.go | 125 ++++ internal/world/terrain.go | 646 ++++++++++++++++++ proto/cityio/entity/v1/terrain.proto | 61 ++ proto/cityio/service/v1/map.proto | 26 + 15 files changed, 1768 insertions(+), 42 deletions(-) create mode 100644 internal/gen/cityio/entity/v1/terrain.pb.go create mode 100644 internal/world/hex.go create mode 100644 internal/world/noise.go create mode 100644 internal/world/placement.go create mode 100644 internal/world/terrain.go create mode 100644 proto/cityio/entity/v1/terrain.proto diff --git a/cmd/main.go b/cmd/main.go index 58e1c73..6c6a9b6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -18,12 +18,14 @@ import ( "cityio/internal/cluster" "cityio/internal/config" + "cityio/internal/constants" "cityio/internal/database" "cityio/internal/logger" "cityio/internal/metrics" "cityio/internal/persistence" "cityio/internal/rpc" "cityio/internal/setup" + "cityio/internal/world" ) func main() { @@ -43,13 +45,20 @@ func main() { slog.InfoContext(ctx, "starting cityio backend") db := database.NewDB(ctx, cfg.DatabaseDSN()) - store := persistence.New(db) + + // The map is regenerated from a fixed seed on every boot rather than + // persisted, so it survives the reset that wipes everything else. + gameWorld := world.Generate(constants.MapSize, constants.MapSize, constants.WorldSeed) + slog.InfoContext(ctx, "generated world", "width", gameWorld.Width, "height", gameWorld.Height, "seed", gameWorld.Seed) + + store := persistence.New(db, gameWorld) store.Start(ctx) cl := cluster.NewRuntime(ctx, store, cfg.Environment) setup.Run(ctx, &setup.Deps{ DB: db, Cluster: cl, + World: gameWorld, }) // shutdownCtx is cancelled when we receive SIGINT/SIGTERM. The RPC server @@ -63,7 +72,7 @@ func main() { // gauges. metrics.StartSnapshot(shutdownCtx, store) - server := rpc.NewServer(shutdownCtx, cl, store, cfg.JWTSecret) + server := rpc.NewServer(shutdownCtx, cl, store, gameWorld, cfg.JWTSecret) handler := cors.New(cors.Options{ AllowOriginFunc: func(origin string) bool { if origin == "http://localhost:5173" || origin == "http://localhost:4173" { diff --git a/internal/constants/constants.go b/internal/constants/constants.go index ac76cb6..181baaf 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -53,6 +53,12 @@ const ( TroopMovementDuration = 1 // time it takes to cross 1 tile VisionRadius = 3 // Chebyshev distance beyond owned city edges that a player can see + + // WorldSeed fixes the shape of the map. Terrain is regenerated from it on + // every boot rather than persisted, so the world survives restarts even + // though everything else is wiped. Changing it reshapes the map for + // everyone. + WorldSeed = 0xc17e0 ) type TownConfig struct { diff --git a/internal/gen/cityio/entity/v1/terrain.pb.go b/internal/gen/cityio/entity/v1/terrain.pb.go new file mode 100644 index 0000000..5f49c94 --- /dev/null +++ b/internal/gen/cityio/entity/v1/terrain.pb.go @@ -0,0 +1,376 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: cityio/entity/v1/terrain.proto + +package entityv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// TerrainType is the ground cover of a tile. +type TerrainType int32 + +const ( + TerrainType_TERRAIN_TYPE_UNSPECIFIED TerrainType = 0 + TerrainType_TERRAIN_TYPE_DEEP_OCEAN TerrainType = 1 + TerrainType_TERRAIN_TYPE_OCEAN TerrainType = 2 + TerrainType_TERRAIN_TYPE_COAST TerrainType = 3 + TerrainType_TERRAIN_TYPE_LAKE TerrainType = 4 + TerrainType_TERRAIN_TYPE_BEACH TerrainType = 5 + TerrainType_TERRAIN_TYPE_GRASSLAND TerrainType = 6 + TerrainType_TERRAIN_TYPE_PLAINS TerrainType = 7 + TerrainType_TERRAIN_TYPE_DESERT TerrainType = 8 + TerrainType_TERRAIN_TYPE_TUNDRA TerrainType = 9 + TerrainType_TERRAIN_TYPE_SNOW TerrainType = 10 +) + +// Enum value maps for TerrainType. +var ( + TerrainType_name = map[int32]string{ + 0: "TERRAIN_TYPE_UNSPECIFIED", + 1: "TERRAIN_TYPE_DEEP_OCEAN", + 2: "TERRAIN_TYPE_OCEAN", + 3: "TERRAIN_TYPE_COAST", + 4: "TERRAIN_TYPE_LAKE", + 5: "TERRAIN_TYPE_BEACH", + 6: "TERRAIN_TYPE_GRASSLAND", + 7: "TERRAIN_TYPE_PLAINS", + 8: "TERRAIN_TYPE_DESERT", + 9: "TERRAIN_TYPE_TUNDRA", + 10: "TERRAIN_TYPE_SNOW", + } + TerrainType_value = map[string]int32{ + "TERRAIN_TYPE_UNSPECIFIED": 0, + "TERRAIN_TYPE_DEEP_OCEAN": 1, + "TERRAIN_TYPE_OCEAN": 2, + "TERRAIN_TYPE_COAST": 3, + "TERRAIN_TYPE_LAKE": 4, + "TERRAIN_TYPE_BEACH": 5, + "TERRAIN_TYPE_GRASSLAND": 6, + "TERRAIN_TYPE_PLAINS": 7, + "TERRAIN_TYPE_DESERT": 8, + "TERRAIN_TYPE_TUNDRA": 9, + "TERRAIN_TYPE_SNOW": 10, + } +) + +func (x TerrainType) Enum() *TerrainType { + p := new(TerrainType) + *p = x + return p +} + +func (x TerrainType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TerrainType) Descriptor() protoreflect.EnumDescriptor { + return file_cityio_entity_v1_terrain_proto_enumTypes[0].Descriptor() +} + +func (TerrainType) Type() protoreflect.EnumType { + return &file_cityio_entity_v1_terrain_proto_enumTypes[0] +} + +func (x TerrainType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TerrainType.Descriptor instead. +func (TerrainType) EnumDescriptor() ([]byte, []int) { + return file_cityio_entity_v1_terrain_proto_rawDescGZIP(), []int{0} +} + +// ReliefType is the landform, drawn over the ground. +type ReliefType int32 + +const ( + ReliefType_RELIEF_TYPE_UNSPECIFIED ReliefType = 0 + ReliefType_RELIEF_TYPE_FLAT ReliefType = 1 + ReliefType_RELIEF_TYPE_HILLS ReliefType = 2 + ReliefType_RELIEF_TYPE_MOUNTAINS ReliefType = 3 +) + +// Enum value maps for ReliefType. +var ( + ReliefType_name = map[int32]string{ + 0: "RELIEF_TYPE_UNSPECIFIED", + 1: "RELIEF_TYPE_FLAT", + 2: "RELIEF_TYPE_HILLS", + 3: "RELIEF_TYPE_MOUNTAINS", + } + ReliefType_value = map[string]int32{ + "RELIEF_TYPE_UNSPECIFIED": 0, + "RELIEF_TYPE_FLAT": 1, + "RELIEF_TYPE_HILLS": 2, + "RELIEF_TYPE_MOUNTAINS": 3, + } +) + +func (x ReliefType) Enum() *ReliefType { + p := new(ReliefType) + *p = x + return p +} + +func (x ReliefType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ReliefType) Descriptor() protoreflect.EnumDescriptor { + return file_cityio_entity_v1_terrain_proto_enumTypes[1].Descriptor() +} + +func (ReliefType) Type() protoreflect.EnumType { + return &file_cityio_entity_v1_terrain_proto_enumTypes[1] +} + +func (x ReliefType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ReliefType.Descriptor instead. +func (ReliefType) EnumDescriptor() ([]byte, []int) { + return file_cityio_entity_v1_terrain_proto_rawDescGZIP(), []int{1} +} + +// FeatureType is vegetation or surface cover, drawn over both. The unspecified +// zero doubles as "no feature": a tile with none specified simply has none. +type FeatureType int32 + +const ( + FeatureType_FEATURE_TYPE_UNSPECIFIED FeatureType = 0 + FeatureType_FEATURE_TYPE_FOREST FeatureType = 1 + FeatureType_FEATURE_TYPE_JUNGLE FeatureType = 2 + FeatureType_FEATURE_TYPE_MARSH FeatureType = 3 + FeatureType_FEATURE_TYPE_OASIS FeatureType = 4 + FeatureType_FEATURE_TYPE_ICE FeatureType = 5 +) + +// Enum value maps for FeatureType. +var ( + FeatureType_name = map[int32]string{ + 0: "FEATURE_TYPE_UNSPECIFIED", + 1: "FEATURE_TYPE_FOREST", + 2: "FEATURE_TYPE_JUNGLE", + 3: "FEATURE_TYPE_MARSH", + 4: "FEATURE_TYPE_OASIS", + 5: "FEATURE_TYPE_ICE", + } + FeatureType_value = map[string]int32{ + "FEATURE_TYPE_UNSPECIFIED": 0, + "FEATURE_TYPE_FOREST": 1, + "FEATURE_TYPE_JUNGLE": 2, + "FEATURE_TYPE_MARSH": 3, + "FEATURE_TYPE_OASIS": 4, + "FEATURE_TYPE_ICE": 5, + } +) + +func (x FeatureType) Enum() *FeatureType { + p := new(FeatureType) + *p = x + return p +} + +func (x FeatureType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FeatureType) Descriptor() protoreflect.EnumDescriptor { + return file_cityio_entity_v1_terrain_proto_enumTypes[2].Descriptor() +} + +func (FeatureType) Type() protoreflect.EnumType { + return &file_cityio_entity_v1_terrain_proto_enumTypes[2] +} + +func (x FeatureType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FeatureType.Descriptor instead. +func (FeatureType) EnumDescriptor() ([]byte, []int) { + return file_cityio_entity_v1_terrain_proto_rawDescGZIP(), []int{2} +} + +// SpecialType marks a bonus resource. Decorative for now — terrain has no +// effect on yields. The unspecified zero doubles as "no resource". +type SpecialType int32 + +const ( + SpecialType_SPECIAL_TYPE_UNSPECIFIED SpecialType = 0 + SpecialType_SPECIAL_TYPE_WHEAT SpecialType = 1 + SpecialType_SPECIAL_TYPE_GAME SpecialType = 2 + SpecialType_SPECIAL_TYPE_FURS SpecialType = 3 + SpecialType_SPECIAL_TYPE_FISH SpecialType = 4 + SpecialType_SPECIAL_TYPE_WHALES SpecialType = 5 + SpecialType_SPECIAL_TYPE_COAL SpecialType = 6 + SpecialType_SPECIAL_TYPE_IRON SpecialType = 7 + SpecialType_SPECIAL_TYPE_GOLD SpecialType = 8 + SpecialType_SPECIAL_TYPE_GEMS SpecialType = 9 +) + +// Enum value maps for SpecialType. +var ( + SpecialType_name = map[int32]string{ + 0: "SPECIAL_TYPE_UNSPECIFIED", + 1: "SPECIAL_TYPE_WHEAT", + 2: "SPECIAL_TYPE_GAME", + 3: "SPECIAL_TYPE_FURS", + 4: "SPECIAL_TYPE_FISH", + 5: "SPECIAL_TYPE_WHALES", + 6: "SPECIAL_TYPE_COAL", + 7: "SPECIAL_TYPE_IRON", + 8: "SPECIAL_TYPE_GOLD", + 9: "SPECIAL_TYPE_GEMS", + } + SpecialType_value = map[string]int32{ + "SPECIAL_TYPE_UNSPECIFIED": 0, + "SPECIAL_TYPE_WHEAT": 1, + "SPECIAL_TYPE_GAME": 2, + "SPECIAL_TYPE_FURS": 3, + "SPECIAL_TYPE_FISH": 4, + "SPECIAL_TYPE_WHALES": 5, + "SPECIAL_TYPE_COAL": 6, + "SPECIAL_TYPE_IRON": 7, + "SPECIAL_TYPE_GOLD": 8, + "SPECIAL_TYPE_GEMS": 9, + } +) + +func (x SpecialType) Enum() *SpecialType { + p := new(SpecialType) + *p = x + return p +} + +func (x SpecialType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SpecialType) Descriptor() protoreflect.EnumDescriptor { + return file_cityio_entity_v1_terrain_proto_enumTypes[3].Descriptor() +} + +func (SpecialType) Type() protoreflect.EnumType { + return &file_cityio_entity_v1_terrain_proto_enumTypes[3] +} + +func (x SpecialType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SpecialType.Descriptor instead. +func (SpecialType) EnumDescriptor() ([]byte, []int) { + return file_cityio_entity_v1_terrain_proto_rawDescGZIP(), []int{3} +} + +var File_cityio_entity_v1_terrain_proto protoreflect.FileDescriptor + +const file_cityio_entity_v1_terrain_proto_rawDesc = "" + + "\n" + + "\x1ecityio/entity/v1/terrain.proto\x12\x10cityio.entity.v1*\xa5\x02\n" + + "\vTerrainType\x12\x1c\n" + + "\x18TERRAIN_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n" + + "\x17TERRAIN_TYPE_DEEP_OCEAN\x10\x01\x12\x16\n" + + "\x12TERRAIN_TYPE_OCEAN\x10\x02\x12\x16\n" + + "\x12TERRAIN_TYPE_COAST\x10\x03\x12\x15\n" + + "\x11TERRAIN_TYPE_LAKE\x10\x04\x12\x16\n" + + "\x12TERRAIN_TYPE_BEACH\x10\x05\x12\x1a\n" + + "\x16TERRAIN_TYPE_GRASSLAND\x10\x06\x12\x17\n" + + "\x13TERRAIN_TYPE_PLAINS\x10\a\x12\x17\n" + + "\x13TERRAIN_TYPE_DESERT\x10\b\x12\x17\n" + + "\x13TERRAIN_TYPE_TUNDRA\x10\t\x12\x15\n" + + "\x11TERRAIN_TYPE_SNOW\x10\n" + + "*q\n" + + "\n" + + "ReliefType\x12\x1b\n" + + "\x17RELIEF_TYPE_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10RELIEF_TYPE_FLAT\x10\x01\x12\x15\n" + + "\x11RELIEF_TYPE_HILLS\x10\x02\x12\x19\n" + + "\x15RELIEF_TYPE_MOUNTAINS\x10\x03*\xa3\x01\n" + + "\vFeatureType\x12\x1c\n" + + "\x18FEATURE_TYPE_UNSPECIFIED\x10\x00\x12\x17\n" + + "\x13FEATURE_TYPE_FOREST\x10\x01\x12\x17\n" + + "\x13FEATURE_TYPE_JUNGLE\x10\x02\x12\x16\n" + + "\x12FEATURE_TYPE_MARSH\x10\x03\x12\x16\n" + + "\x12FEATURE_TYPE_OASIS\x10\x04\x12\x14\n" + + "\x10FEATURE_TYPE_ICE\x10\x05*\xfd\x01\n" + + "\vSpecialType\x12\x1c\n" + + "\x18SPECIAL_TYPE_UNSPECIFIED\x10\x00\x12\x16\n" + + "\x12SPECIAL_TYPE_WHEAT\x10\x01\x12\x15\n" + + "\x11SPECIAL_TYPE_GAME\x10\x02\x12\x15\n" + + "\x11SPECIAL_TYPE_FURS\x10\x03\x12\x15\n" + + "\x11SPECIAL_TYPE_FISH\x10\x04\x12\x17\n" + + "\x13SPECIAL_TYPE_WHALES\x10\x05\x12\x15\n" + + "\x11SPECIAL_TYPE_COAL\x10\x06\x12\x15\n" + + "\x11SPECIAL_TYPE_IRON\x10\a\x12\x15\n" + + "\x11SPECIAL_TYPE_GOLD\x10\b\x12\x15\n" + + "\x11SPECIAL_TYPE_GEMS\x10\tB\xb5\x01\n" + + "\x14com.cityio.entity.v1B\fTerrainProtoP\x01Z-cityio/internal/gen/cityio/entity/v1;entityv1\xa2\x02\x03CEX\xaa\x02\x10Cityio.Entity.V1\xca\x02\x10Cityio\\Entity\\V1\xe2\x02\x1cCityio\\Entity\\V1\\GPBMetadata\xea\x02\x12Cityio::Entity::V1b\x06proto3" + +var ( + file_cityio_entity_v1_terrain_proto_rawDescOnce sync.Once + file_cityio_entity_v1_terrain_proto_rawDescData []byte +) + +func file_cityio_entity_v1_terrain_proto_rawDescGZIP() []byte { + file_cityio_entity_v1_terrain_proto_rawDescOnce.Do(func() { + file_cityio_entity_v1_terrain_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_cityio_entity_v1_terrain_proto_rawDesc), len(file_cityio_entity_v1_terrain_proto_rawDesc))) + }) + return file_cityio_entity_v1_terrain_proto_rawDescData +} + +var file_cityio_entity_v1_terrain_proto_enumTypes = make([]protoimpl.EnumInfo, 4) +var file_cityio_entity_v1_terrain_proto_goTypes = []any{ + (TerrainType)(0), // 0: cityio.entity.v1.TerrainType + (ReliefType)(0), // 1: cityio.entity.v1.ReliefType + (FeatureType)(0), // 2: cityio.entity.v1.FeatureType + (SpecialType)(0), // 3: cityio.entity.v1.SpecialType +} +var file_cityio_entity_v1_terrain_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_cityio_entity_v1_terrain_proto_init() } +func file_cityio_entity_v1_terrain_proto_init() { + if File_cityio_entity_v1_terrain_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_cityio_entity_v1_terrain_proto_rawDesc), len(file_cityio_entity_v1_terrain_proto_rawDesc)), + NumEnums: 4, + NumMessages: 0, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_cityio_entity_v1_terrain_proto_goTypes, + DependencyIndexes: file_cityio_entity_v1_terrain_proto_depIdxs, + EnumInfos: file_cityio_entity_v1_terrain_proto_enumTypes, + }.Build() + File_cityio_entity_v1_terrain_proto = out.File + file_cityio_entity_v1_terrain_proto_goTypes = nil + file_cityio_entity_v1_terrain_proto_depIdxs = nil +} diff --git a/internal/gen/cityio/service/v1/map.pb.go b/internal/gen/cityio/service/v1/map.pb.go index 7047f08..9490494 100644 --- a/internal/gen/cityio/service/v1/map.pb.go +++ b/internal/gen/cityio/service/v1/map.pb.go @@ -283,6 +283,154 @@ func (x *GetTileResponse) GetTile() *Tile { return nil } +type GetTerrainRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTerrainRequest) Reset() { + *x = GetTerrainRequest{} + mi := &file_cityio_service_v1_map_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTerrainRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTerrainRequest) ProtoMessage() {} + +func (x *GetTerrainRequest) ProtoReflect() protoreflect.Message { + mi := &file_cityio_service_v1_map_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTerrainRequest.ProtoReflect.Descriptor instead. +func (*GetTerrainRequest) Descriptor() ([]byte, []int) { + return file_cityio_service_v1_map_proto_rawDescGZIP(), []int{5} +} + +// GetTerrainResponse carries the whole map in one call. +// +// Each plane is packed one byte per tile in row-major order, so the value for +// (x, y) is at index y * width + x. At the current map size that is about 5 KB +// per plane — small enough that chunking or viewport queries would cost more +// than they save, and it lets the client cache the world for the session. +// +// Terrain is generated by the server from `seed` and does not change, so this +// response is stable for the lifetime of a world. +type GetTerrainResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Width int32 `protobuf:"varint,1,opt,name=width,proto3" json:"width,omitempty"` + Height int32 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` + Seed int64 `protobuf:"varint,3,opt,name=seed,proto3" json:"seed,omitempty"` + Terrain []byte `protobuf:"bytes,4,opt,name=terrain,proto3" json:"terrain,omitempty"` // cityio.entity.v1.TerrainType per tile + Relief []byte `protobuf:"bytes,5,opt,name=relief,proto3" json:"relief,omitempty"` // cityio.entity.v1.ReliefType per tile + Feature []byte `protobuf:"bytes,6,opt,name=feature,proto3" json:"feature,omitempty"` // cityio.entity.v1.FeatureType per tile + Special []byte `protobuf:"bytes,7,opt,name=special,proto3" json:"special,omitempty"` // cityio.entity.v1.SpecialType per tile + // rivers holds a 6-bit mask per tile: bit i means a river continues toward + // neighbour i. Both tiles either side of a step carry the reciprocal bit, so + // each renders its own half and rivers occupy no tile of their own. + Rivers []byte `protobuf:"bytes,8,opt,name=rivers,proto3" json:"rivers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTerrainResponse) Reset() { + *x = GetTerrainResponse{} + mi := &file_cityio_service_v1_map_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTerrainResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTerrainResponse) ProtoMessage() {} + +func (x *GetTerrainResponse) ProtoReflect() protoreflect.Message { + mi := &file_cityio_service_v1_map_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTerrainResponse.ProtoReflect.Descriptor instead. +func (*GetTerrainResponse) Descriptor() ([]byte, []int) { + return file_cityio_service_v1_map_proto_rawDescGZIP(), []int{6} +} + +func (x *GetTerrainResponse) GetWidth() int32 { + if x != nil { + return x.Width + } + return 0 +} + +func (x *GetTerrainResponse) GetHeight() int32 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *GetTerrainResponse) GetSeed() int64 { + if x != nil { + return x.Seed + } + return 0 +} + +func (x *GetTerrainResponse) GetTerrain() []byte { + if x != nil { + return x.Terrain + } + return nil +} + +func (x *GetTerrainResponse) GetRelief() []byte { + if x != nil { + return x.Relief + } + return nil +} + +func (x *GetTerrainResponse) GetFeature() []byte { + if x != nil { + return x.Feature + } + return nil +} + +func (x *GetTerrainResponse) GetSpecial() []byte { + if x != nil { + return x.Special + } + return nil +} + +func (x *GetTerrainResponse) GetRivers() []byte { + if x != nil { + return x.Rivers + } + return nil +} + var File_cityio_service_v1_map_proto protoreflect.FileDescriptor const file_cityio_service_v1_map_proto_rawDesc = "" + @@ -306,11 +454,23 @@ const file_cityio_service_v1_map_proto_rawDesc = "" + "\x0eGetTileRequest\x125\n" + "\x06coords\x18\x01 \x01(\v2\x1d.cityio.entity.v1.CoordinatesR\x06coords\">\n" + "\x0fGetTileResponse\x12+\n" + - "\x04tile\x18\x01 \x01(\v2\x17.cityio.service.v1.TileR\x04tile2\xad\x01\n" + + "\x04tile\x18\x01 \x01(\v2\x17.cityio.service.v1.TileR\x04tile\"\x13\n" + + "\x11GetTerrainRequest\"\xd4\x01\n" + + "\x12GetTerrainResponse\x12\x14\n" + + "\x05width\x18\x01 \x01(\x05R\x05width\x12\x16\n" + + "\x06height\x18\x02 \x01(\x05R\x06height\x12\x12\n" + + "\x04seed\x18\x03 \x01(\x03R\x04seed\x12\x18\n" + + "\aterrain\x18\x04 \x01(\fR\aterrain\x12\x16\n" + + "\x06relief\x18\x05 \x01(\fR\x06relief\x12\x18\n" + + "\afeature\x18\x06 \x01(\fR\afeature\x12\x18\n" + + "\aspecial\x18\a \x01(\fR\aspecial\x12\x16\n" + + "\x06rivers\x18\b \x01(\fR\x06rivers2\x88\x02\n" + "\n" + "MapService\x12M\n" + "\x06GetMap\x12 .cityio.service.v1.GetMapRequest\x1a!.cityio.service.v1.GetMapResponse\x12P\n" + - "\aGetTile\x12!.cityio.service.v1.GetTileRequest\x1a\".cityio.service.v1.GetTileResponseB\xb8\x01\n" + + "\aGetTile\x12!.cityio.service.v1.GetTileRequest\x1a\".cityio.service.v1.GetTileResponse\x12Y\n" + + "\n" + + "GetTerrain\x12$.cityio.service.v1.GetTerrainRequest\x1a%.cityio.service.v1.GetTerrainResponseB\xb8\x01\n" + "\x15com.cityio.service.v1B\bMapProtoP\x01Z/cityio/internal/gen/cityio/service/v1;servicev1\xa2\x02\x03CSX\xaa\x02\x11Cityio.Service.V1\xca\x02\x11Cityio\\Service\\V1\xe2\x02\x1dCityio\\Service\\V1\\GPBMetadata\xea\x02\x13Cityio::Service::V1b\x06proto3" var ( @@ -325,34 +485,38 @@ func file_cityio_service_v1_map_proto_rawDescGZIP() []byte { return file_cityio_service_v1_map_proto_rawDescData } -var file_cityio_service_v1_map_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_cityio_service_v1_map_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_cityio_service_v1_map_proto_goTypes = []any{ - (*GetMapRequest)(nil), // 0: cityio.service.v1.GetMapRequest - (*GetMapResponse)(nil), // 1: cityio.service.v1.GetMapResponse - (*Tile)(nil), // 2: cityio.service.v1.Tile - (*GetTileRequest)(nil), // 3: cityio.service.v1.GetTileRequest - (*GetTileResponse)(nil), // 4: cityio.service.v1.GetTileResponse - (*v1.CityId)(nil), // 5: cityio.entity.v1.CityId - (*v1.BuildingId)(nil), // 6: cityio.entity.v1.BuildingId - (*v1.EntityBag)(nil), // 7: cityio.entity.v1.EntityBag - (*v1.ArmyId)(nil), // 8: cityio.entity.v1.ArmyId - (*v1.Coordinates)(nil), // 9: cityio.entity.v1.Coordinates + (*GetMapRequest)(nil), // 0: cityio.service.v1.GetMapRequest + (*GetMapResponse)(nil), // 1: cityio.service.v1.GetMapResponse + (*Tile)(nil), // 2: cityio.service.v1.Tile + (*GetTileRequest)(nil), // 3: cityio.service.v1.GetTileRequest + (*GetTileResponse)(nil), // 4: cityio.service.v1.GetTileResponse + (*GetTerrainRequest)(nil), // 5: cityio.service.v1.GetTerrainRequest + (*GetTerrainResponse)(nil), // 6: cityio.service.v1.GetTerrainResponse + (*v1.CityId)(nil), // 7: cityio.entity.v1.CityId + (*v1.BuildingId)(nil), // 8: cityio.entity.v1.BuildingId + (*v1.EntityBag)(nil), // 9: cityio.entity.v1.EntityBag + (*v1.ArmyId)(nil), // 10: cityio.entity.v1.ArmyId + (*v1.Coordinates)(nil), // 11: cityio.entity.v1.Coordinates } var file_cityio_service_v1_map_proto_depIdxs = []int32{ - 5, // 0: cityio.service.v1.GetMapResponse.city_ids:type_name -> cityio.entity.v1.CityId - 6, // 1: cityio.service.v1.GetMapResponse.building_ids:type_name -> cityio.entity.v1.BuildingId - 7, // 2: cityio.service.v1.GetMapResponse.entities:type_name -> cityio.entity.v1.EntityBag - 5, // 3: cityio.service.v1.Tile.city_id:type_name -> cityio.entity.v1.CityId - 6, // 4: cityio.service.v1.Tile.building_id:type_name -> cityio.entity.v1.BuildingId - 8, // 5: cityio.service.v1.Tile.army_ids:type_name -> cityio.entity.v1.ArmyId - 9, // 6: cityio.service.v1.GetTileRequest.coords:type_name -> cityio.entity.v1.Coordinates + 7, // 0: cityio.service.v1.GetMapResponse.city_ids:type_name -> cityio.entity.v1.CityId + 8, // 1: cityio.service.v1.GetMapResponse.building_ids:type_name -> cityio.entity.v1.BuildingId + 9, // 2: cityio.service.v1.GetMapResponse.entities:type_name -> cityio.entity.v1.EntityBag + 7, // 3: cityio.service.v1.Tile.city_id:type_name -> cityio.entity.v1.CityId + 8, // 4: cityio.service.v1.Tile.building_id:type_name -> cityio.entity.v1.BuildingId + 10, // 5: cityio.service.v1.Tile.army_ids:type_name -> cityio.entity.v1.ArmyId + 11, // 6: cityio.service.v1.GetTileRequest.coords:type_name -> cityio.entity.v1.Coordinates 2, // 7: cityio.service.v1.GetTileResponse.tile:type_name -> cityio.service.v1.Tile 0, // 8: cityio.service.v1.MapService.GetMap:input_type -> cityio.service.v1.GetMapRequest 3, // 9: cityio.service.v1.MapService.GetTile:input_type -> cityio.service.v1.GetTileRequest - 1, // 10: cityio.service.v1.MapService.GetMap:output_type -> cityio.service.v1.GetMapResponse - 4, // 11: cityio.service.v1.MapService.GetTile:output_type -> cityio.service.v1.GetTileResponse - 10, // [10:12] is the sub-list for method output_type - 8, // [8:10] is the sub-list for method input_type + 5, // 10: cityio.service.v1.MapService.GetTerrain:input_type -> cityio.service.v1.GetTerrainRequest + 1, // 11: cityio.service.v1.MapService.GetMap:output_type -> cityio.service.v1.GetMapResponse + 4, // 12: cityio.service.v1.MapService.GetTile:output_type -> cityio.service.v1.GetTileResponse + 6, // 13: cityio.service.v1.MapService.GetTerrain:output_type -> cityio.service.v1.GetTerrainResponse + 11, // [11:14] is the sub-list for method output_type + 8, // [8:11] is the sub-list for method input_type 8, // [8:8] is the sub-list for extension type_name 8, // [8:8] is the sub-list for extension extendee 0, // [0:8] is the sub-list for field type_name @@ -370,7 +534,7 @@ func file_cityio_service_v1_map_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_cityio_service_v1_map_proto_rawDesc), len(file_cityio_service_v1_map_proto_rawDesc)), NumEnums: 0, - NumMessages: 5, + NumMessages: 7, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/gen/cityio/service/v1/servicev1connect/map.connect.go b/internal/gen/cityio/service/v1/servicev1connect/map.connect.go index 11ee7ad..a51ff93 100644 --- a/internal/gen/cityio/service/v1/servicev1connect/map.connect.go +++ b/internal/gen/cityio/service/v1/servicev1connect/map.connect.go @@ -37,12 +37,15 @@ const ( MapServiceGetMapProcedure = "/cityio.service.v1.MapService/GetMap" // MapServiceGetTileProcedure is the fully-qualified name of the MapService's GetTile RPC. MapServiceGetTileProcedure = "/cityio.service.v1.MapService/GetTile" + // MapServiceGetTerrainProcedure is the fully-qualified name of the MapService's GetTerrain RPC. + MapServiceGetTerrainProcedure = "/cityio.service.v1.MapService/GetTerrain" ) // MapServiceClient is a client for the cityio.service.v1.MapService service. type MapServiceClient interface { GetMap(context.Context, *connect.Request[v1.GetMapRequest]) (*connect.Response[v1.GetMapResponse], error) GetTile(context.Context, *connect.Request[v1.GetTileRequest]) (*connect.Response[v1.GetTileResponse], error) + GetTerrain(context.Context, *connect.Request[v1.GetTerrainRequest]) (*connect.Response[v1.GetTerrainResponse], error) } // NewMapServiceClient constructs a client for the cityio.service.v1.MapService service. By default, @@ -68,13 +71,20 @@ func NewMapServiceClient(httpClient connect.HTTPClient, baseURL string, opts ... connect.WithSchema(mapServiceMethods.ByName("GetTile")), connect.WithClientOptions(opts...), ), + getTerrain: connect.NewClient[v1.GetTerrainRequest, v1.GetTerrainResponse]( + httpClient, + baseURL+MapServiceGetTerrainProcedure, + connect.WithSchema(mapServiceMethods.ByName("GetTerrain")), + connect.WithClientOptions(opts...), + ), } } // mapServiceClient implements MapServiceClient. type mapServiceClient struct { - getMap *connect.Client[v1.GetMapRequest, v1.GetMapResponse] - getTile *connect.Client[v1.GetTileRequest, v1.GetTileResponse] + getMap *connect.Client[v1.GetMapRequest, v1.GetMapResponse] + getTile *connect.Client[v1.GetTileRequest, v1.GetTileResponse] + getTerrain *connect.Client[v1.GetTerrainRequest, v1.GetTerrainResponse] } // GetMap calls cityio.service.v1.MapService.GetMap. @@ -87,10 +97,16 @@ func (c *mapServiceClient) GetTile(ctx context.Context, req *connect.Request[v1. return c.getTile.CallUnary(ctx, req) } +// GetTerrain calls cityio.service.v1.MapService.GetTerrain. +func (c *mapServiceClient) GetTerrain(ctx context.Context, req *connect.Request[v1.GetTerrainRequest]) (*connect.Response[v1.GetTerrainResponse], error) { + return c.getTerrain.CallUnary(ctx, req) +} + // MapServiceHandler is an implementation of the cityio.service.v1.MapService service. type MapServiceHandler interface { GetMap(context.Context, *connect.Request[v1.GetMapRequest]) (*connect.Response[v1.GetMapResponse], error) GetTile(context.Context, *connect.Request[v1.GetTileRequest]) (*connect.Response[v1.GetTileResponse], error) + GetTerrain(context.Context, *connect.Request[v1.GetTerrainRequest]) (*connect.Response[v1.GetTerrainResponse], error) } // NewMapServiceHandler builds an HTTP handler from the service implementation. It returns the path @@ -112,12 +128,20 @@ func NewMapServiceHandler(svc MapServiceHandler, opts ...connect.HandlerOption) connect.WithSchema(mapServiceMethods.ByName("GetTile")), connect.WithHandlerOptions(opts...), ) + mapServiceGetTerrainHandler := connect.NewUnaryHandler( + MapServiceGetTerrainProcedure, + svc.GetTerrain, + connect.WithSchema(mapServiceMethods.ByName("GetTerrain")), + connect.WithHandlerOptions(opts...), + ) return "/cityio.service.v1.MapService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case MapServiceGetMapProcedure: mapServiceGetMapHandler.ServeHTTP(w, r) case MapServiceGetTileProcedure: mapServiceGetTileHandler.ServeHTTP(w, r) + case MapServiceGetTerrainProcedure: + mapServiceGetTerrainHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -134,3 +158,7 @@ func (UnimplementedMapServiceHandler) GetMap(context.Context, *connect.Request[v func (UnimplementedMapServiceHandler) GetTile(context.Context, *connect.Request[v1.GetTileRequest]) (*connect.Response[v1.GetTileResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("cityio.service.v1.MapService.GetTile is not implemented")) } + +func (UnimplementedMapServiceHandler) GetTerrain(context.Context, *connect.Request[v1.GetTerrainRequest]) (*connect.Response[v1.GetTerrainResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("cityio.service.v1.MapService.GetTerrain is not implemented")) +} diff --git a/internal/persistence/store.go b/internal/persistence/store.go index 693b076..6d020ee 100644 --- a/internal/persistence/store.go +++ b/internal/persistence/store.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "log/slog" + "math/rand" "sync" "time" @@ -21,6 +22,7 @@ import ( "cityio/internal/database" "cityio/internal/domain" "cityio/internal/metrics" + "cityio/internal/world" ) const batchSize = 5000 @@ -30,7 +32,8 @@ var ErrNotFound = errors.New("not found") // Store implements ports.Store over a sqlc Querier backed by a pgx pool. type Store struct { - db database.Querier + db database.Querier + world *world.World mu sync.Mutex userBuffer map[string]domain.User @@ -43,9 +46,10 @@ type Store struct { } // New constructs a Store. Call Start to begin periodic flushing. -func New(db database.Querier) *Store { +func New(db database.Querier, w *world.World) *Store { return &Store{ db: db, + world: w, userBuffer: make(map[string]domain.User), cityBuffer: make(map[string]domain.City), buildingBuffer: make(map[string]domain.Building), @@ -82,7 +86,36 @@ func (s *Store) Stop(ctx context.Context) { s.flush(ctx) } +// FindEmptyCityBlock picks a starting block for a new city. +// +// With terrain available the search runs in memory over the whole map, because +// the SQL query only knows about collisions with other cities: the blocks it +// leaves free are precisely the water and mountain that town seeding already +// rejected, so drawing from it seats players in the worst ground on the map. +// The query remains the fallback for when no world is loaded. func (s *Store) FindEmptyCityBlock(ctx context.Context, size int) (domain.Coordinates, error) { + if s.world != nil { + cities, err := s.GetAllCities(ctx) + if err != nil { + return domain.Coordinates{}, err + } + occupied := blockedTiles(cities, s.world.Width, s.world.Height) + collides := func(x, y int) bool { + for dx := 0; dx < size; dx++ { + for dy := 0; dy < size; dy++ { + if occupied[(y+dy)*s.world.Width+(x+dx)] { + return true + } + } + } + return false + } + if x, y, ok := s.world.FindStart(size, collides, rand.Intn); ok { + return domain.Coordinates{X: x, Y: y}, nil + } + slog.WarnContext(ctx, "no habitable start block left; falling back to any empty block") + } + row, err := s.db.FindEmptyCityBlock(ctx, database.FindEmptyCityBlockParams{ MapWidth: constants.MapSize, MapHeight: constants.MapSize, @@ -94,6 +127,24 @@ func (s *Store) FindEmptyCityBlock(ctx context.Context, size int) (domain.Coordi return domain.Coordinates{X: int(row.X), Y: int(row.Y)}, nil } +// blockedTiles marks every tile covered by an existing city, expanded by the +// one-tile gap the placement rules require between settlements. +func blockedTiles(cities []domain.City, width, height int) []bool { + blocked := make([]bool, width*height) + for _, c := range cities { + for dx := -1; dx <= c.Size; dx++ { + for dy := -1; dy <= c.Size; dy++ { + x, y := c.StartX+dx, c.StartY+dy + if x < 0 || y < 0 || x >= width || y >= height { + continue + } + blocked[y*width+x] = true + } + } + } + return blocked +} + func (s *Store) GetUserByIdentifier(ctx context.Context, identifier string) (*domain.User, error) { row, err := s.db.GetUserByIdentifier(ctx, identifier) if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/rpc/map.go b/internal/rpc/map.go index efe6f19..06f332a 100644 --- a/internal/rpc/map.go +++ b/internal/rpc/map.go @@ -69,6 +69,31 @@ func (h *mapHandler) GetMap(ctx context.Context, req *connect.Request[servicev1. }), nil } +// GetTerrain returns the whole map in one response. Terrain is generated once +// at boot and never changes, so this is deliberately not filtered by vision or +// paged: the planes total a few kilobytes and the client caches them for the +// session. Fog of war hides entities, which is the information that matters — +// the shape of the coastline is not a secret worth a per-viewport query. +func (h *mapHandler) GetTerrain(ctx context.Context, req *connect.Request[servicev1.GetTerrainRequest]) (*connect.Response[servicev1.GetTerrainResponse], error) { + w := h.srv.world + if w == nil { + return nil, connect.NewError(connect.CodeUnavailable, errors.New("world not generated")) + } + + // The world's plane values are numbered to match the proto enums exactly, + // so these copy straight out with no remapping. + return connect.NewResponse(&servicev1.GetTerrainResponse{ + Width: int32(w.Width), + Height: int32(w.Height), + Seed: w.Seed, + Terrain: append([]byte(nil), w.Terrain...), + Relief: append([]byte(nil), w.Relief...), + Feature: append([]byte(nil), w.Feature...), + Special: append([]byte(nil), w.Special...), + Rivers: append([]byte(nil), w.Rivers...), + }), nil +} + func (h *mapHandler) GetTile(ctx context.Context, req *connect.Request[servicev1.GetTileRequest]) (*connect.Response[servicev1.GetTileResponse], error) { x := int(req.Msg.GetCoords().GetX()) y := int(req.Msg.GetCoords().GetY()) diff --git a/internal/rpc/rpc.go b/internal/rpc/rpc.go index 3a85589..d7f89a6 100644 --- a/internal/rpc/rpc.go +++ b/internal/rpc/rpc.go @@ -16,12 +16,14 @@ import ( "cityio/internal/gen/cityio/service/v1/servicev1connect" "cityio/internal/metrics" "cityio/internal/ports" + "cityio/internal/world" ) // Server wires the Connect services to the actor cluster and persistence store. type Server struct { cluster ports.ClusterProvider store ports.Store + world *world.World jwtSecret string // shutdownCtx is cancelled when the process is shutting down. Long-lived @@ -34,8 +36,8 @@ type Server struct { // NewServer constructs an RPC server backed by the given cluster and store. // shutdownCtx is cancelled by main on SIGINT/SIGTERM; streaming handlers // observe it and close their streams. -func NewServer(shutdownCtx context.Context, cluster ports.ClusterProvider, store ports.Store, jwtSecret string) *Server { - return &Server{cluster: cluster, store: store, jwtSecret: jwtSecret, shutdownCtx: shutdownCtx} +func NewServer(shutdownCtx context.Context, cluster ports.ClusterProvider, store ports.Store, gameWorld *world.World, jwtSecret string) *Server { + return &Server{cluster: cluster, store: store, world: gameWorld, jwtSecret: jwtSecret, shutdownCtx: shutdownCtx} } func (s *Server) ownedCities(ctx context.Context) ([]domain.City, error) { diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 06bf082..afadeb6 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -18,11 +18,13 @@ import ( "cityio/internal/logger" "cityio/internal/ports" "cityio/internal/services" + "cityio/internal/world" ) type Deps struct { DB database.Querier Cluster ports.ClusterProvider + World *world.World } func Run(ctx context.Context, deps *Deps) { @@ -131,14 +133,22 @@ func reset(ctx context.Context, deps *Deps) error { slog.ErrorContext(ctx, "error resetting user fields", "error", err) } + // Bounded: canPlace now also rejects unbuildable terrain, so on a map + // with no room left this would otherwise spin forever. var startX, startY int - for { + placed := false + for attempt := 0; attempt < 4000; attempt++ { startX = r.Intn(constants.MapSize - constants.CitySize) startY = r.Intn(constants.MapSize - constants.CitySize) - if canPlace(occupied, startX, startY, constants.CitySize) { + if canPlace(deps.World, occupied, startX, startY, constants.CitySize) { + placed = true break } } + if !placed { + slog.ErrorContext(ctx, "no room to place restored user's city", "user", user.Username) + continue + } cityID := uuid.New().String() err = db.CreateCity(ctx, database.CreateCityParams{ @@ -201,7 +211,7 @@ func reset(ctx context.Context, deps *Deps) error { if x < 0 || y < 0 || x+size > constants.MapSize || y+size > constants.MapSize { continue } - if !canPlace(occupied, x, y, size) { + if !canPlace(deps.World, occupied, x, y, size) { continue } @@ -386,7 +396,12 @@ const ( // townMinSpacing is the Euclidean minimum distance between town centers. // Smaller = denser map. Large enough to leave room for size-5 footprints // (5×5 tiles + 1-tile gap) without canPlace rejecting too many candidates. - townMinSpacing = 5 + // + // Tightened from 5 to 4 when canPlace started rejecting unbuildable ground: + // roughly half of all candidates now land on water, peaks or ice, which + // otherwise halved the number of towns on the map and with it the amount + // there is to expand into. + townMinSpacing = 4 // poissonRetries is k in Bridson's algorithm: how many candidate points // to try around each active sample before retiring it. 30 is the standard @@ -468,15 +483,21 @@ func poissonDiskPoints(rng *rand.Rand, n, minDist, k int) [][2]int { } // canPlace reports whether a city of the given size can be placed at (x, y) -// with at least a 1-tile gap from all occupied cells and from the map boundary. -// Off-map cells are treated as occupied so edge placements have the same gap -// requirement as interior ones — otherwise canPlace is more permissive near -// the borders and town density skews toward the edges. -func canPlace(occupied [][]bool, x, y, size int) bool { +// with at least a 1-tile gap from all occupied cells and from the map boundary, +// on ground that can actually be settled. Off-map cells are treated as occupied +// so edge placements have the same gap requirement as interior ones — otherwise +// canPlace is more permissive near the borders and town density skews toward +// the edges. +func canPlace(w *world.World, occupied [][]bool, x, y, size int) bool { mapSize := len(occupied) if x+size > mapSize || y+size > mapSize { return false } + // Every tile of the footprint must be dry, unfrozen and off the peaks. + // Without this the map is scattered with towns standing in open ocean. + if w != nil && !w.BlockBuildable(x, y, size) { + return false + } for i := -1; i <= size; i++ { for j := -1; j <= size; j++ { nx, ny := x+i, y+j diff --git a/internal/world/hex.go b/internal/world/hex.go new file mode 100644 index 0000000..741542f --- /dev/null +++ b/internal/world/hex.go @@ -0,0 +1,44 @@ +package world + +import "math" + +// The map is a flat-top hex grid in odd-q offset coordinates, squashed +// vertically by iso for the client's 2.5D projection. These constants must stay +// in step with web/src/lib/game/hex.ts: the server decides where rivers run and +// which tiles border which, and the client draws that geometry. +const iso = 0.5 + +var rowPitch = math.Sqrt(3) * iso + +// hexPoint returns a tile centre in unit-hex space, where one hex is 2.0 wide. +// +// Noise is sampled here rather than directly at (col, row). Columns sit 1.5 +// apart while rows sit only rowPitch (~0.866) apart, so sampling in grid space +// stretches every coastline and mountain range by about 1.73x horizontally once +// the map is drawn. +func hexPoint(col, row int) (float64, float64) { + return float64(col) * 1.5, (float64(row) + 0.5*float64(col&1)) * rowPitch +} + +// neighborOffsets holds the six neighbour deltas for odd-q offset coordinates, +// indexed by column parity then by direction 0..5. Direction i shares the edge +// running from vertex i to vertex (i+1)%6, which makes the reciprocal of i +// exactly (i+3)%6 — the property river links rely on to join without a seam. +var neighborOffsets = [2][6][2]int{ + {{1, 0}, {0, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}}, + {{1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {0, -1}, {1, 0}}, +} + +func opposite(dir int) int { return (dir + 3) % 6 } + +// forEachNeighbor calls fn for each in-bounds neighbour of (x, y). +func (w *World) forEachNeighbor(x, y int, fn func(j, dir, nx, ny int)) { + offs := &neighborOffsets[x&1] + for dir := 0; dir < 6; dir++ { + nx, ny := x+offs[dir][0], y+offs[dir][1] + if nx < 0 || ny < 0 || nx >= w.Width || ny >= w.Height { + continue + } + fn(ny*w.Width+nx, dir, nx, ny) + } +} diff --git a/internal/world/noise.go b/internal/world/noise.go new file mode 100644 index 0000000..51922ea --- /dev/null +++ b/internal/world/noise.go @@ -0,0 +1,142 @@ +package world + +import ( + "math" + "sort" +) + +// hash2 is an integer avalanche hash. Written with wrapping uint32 arithmetic +// so it produces the same values as the JavaScript reference implementation the +// generator was tuned against. +func hash2(x, y, seed int) uint32 { + h := uint32(int32(x))*0x27d4eb2d ^ uint32(int32(y))*0x165667b1 ^ uint32(int32(seed))*0x9e3779b9 + h = (h ^ (h >> 15)) * 0x85ebca6b + h = (h ^ (h >> 13)) * 0xc2b2ae35 + return h ^ (h >> 16) +} + +func hash01(x, y, seed int) float64 { + return float64(hash2(x, y, seed)) / 4294967296.0 +} + +const r2 = 0.7071067811865476 + +// Eight unit directions. Gradient noise rather than value noise: a value +// lattice leaves axis-aligned artefacts that read as visibly rectangular +// coastlines once the field is thresholded into land and sea. +var gradients = [8][2]float64{ + {1, 0}, {-1, 0}, {0, 1}, {0, -1}, + {r2, r2}, {-r2, r2}, {r2, -r2}, {-r2, -r2}, +} + +func fade(t float64) float64 { return t * t * t * (t*(t*6-15) + 10) } + +func dotGrad(ix, iy int, dx, dy float64, seed int) float64 { + g := &gradients[hash2(ix, iy, seed)&7] + return g[0]*dx + g[1]*dy +} + +// perlin2 returns gradient noise remapped to roughly [0, 1]. +func perlin2(x, y float64, seed int) float64 { + xi, yi := int(math.Floor(x)), int(math.Floor(y)) + xf, yf := x-float64(xi), y-float64(yi) + u, v := fade(xf), fade(yf) + + n00 := dotGrad(xi, yi, xf, yf, seed) + n10 := dotGrad(xi+1, yi, xf-1, yf, seed) + n01 := dotGrad(xi, yi+1, xf, yf-1, seed) + n11 := dotGrad(xi+1, yi+1, xf-1, yf-1, seed) + + nx0 := n00 + (n10-n00)*u + nx1 := n01 + (n11-n01)*u + return clamp01((nx0+(nx1-nx0)*v)*r2 + 0.5) +} + +// fbm sums octaves of perlin2 at increasing frequency. +func fbm(x, y float64, seed, octaves int, lacunarity, gain float64) float64 { + amp, freq, sum, norm := 1.0, 1.0, 0.0, 0.0 + for i := 0; i < octaves; i++ { + sum += amp * perlin2(x*freq, y*freq, seed+i*1013) + norm += amp + amp *= gain + freq *= lacunarity + } + return sum / norm +} + +// ridged folds the signal about its midpoint, turning rounded blobs into +// creases so mountains form connected ranges rather than isolated lumps. +func ridged(x, y float64, seed, octaves int) float64 { + amp, freq, sum, norm := 1.0, 1.0, 0.0, 0.0 + for i := 0; i < octaves; i++ { + n := 1 - math.Abs(perlin2(x*freq, y*freq, seed+i*2087)*2-1) + sum += amp * n * n + norm += amp + amp *= gain + freq *= lacunarity + } + return sum / norm +} + +// warp offsets a sample point by a low-frequency noise vector, which is what +// turns smooth blobby coastlines into ones with inlets and peninsulas. +func warp(x, y float64, seed int, amp float64) (float64, float64) { + wx := fbm(x+5.2, y+1.3, seed+7717, 3, lacunarity, gain) - 0.5 + wy := fbm(x+9.1, y+4.7, seed+3313, 3, lacunarity, gain) - 0.5 + return x + wx*amp, y + wy*amp +} + +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +func smoothstep(t float64) float64 { + c := clamp01(t) + return c * c * (3 - 2*c) +} + +// quantile returns the value at the given quantile of a sample set, sorting a +// copy rather than the input. +func quantile(values []float64, q float64) float64 { + if len(values) == 0 { + return 1 + } + sorted := make([]float64, len(values)) + copy(sorted, values) + sort.Float64s(sorted) + i := int(math.Round(q * float64(len(sorted)-1))) + if i < 0 { + i = 0 + } + if i >= len(sorted) { + i = len(sorted) - 1 + } + return sorted[i] +} + +// rankNormalize replaces each included value with its rank in [0, 1], +// flattening the distribution. Excluded entries are left at zero. +func rankNormalize(src []float64, include func(i int) bool) []float64 { + idxs := make([]int, 0, len(src)) + for i := range src { + if include(i) { + idxs = append(idxs, i) + } + } + sort.Slice(idxs, func(a, b int) bool { return src[idxs[a]] < src[idxs[b]] }) + out := make([]float64, len(src)) + denom := float64(len(idxs) - 1) + if denom < 1 { + denom = 1 + } + for r, i := range idxs { + out[i] = float64(r) / denom + } + return out +} diff --git a/internal/world/placement.go b/internal/world/placement.go new file mode 100644 index 0000000..c59fa34 --- /dev/null +++ b/internal/world/placement.go @@ -0,0 +1,125 @@ +package world + +// Coastal reports whether a tile touches water. +func (w *World) Coastal(x, y int) bool { + found := false + w.forEachNeighbor(x, y, func(j, _, _, _ int) { + if isWater(w.Terrain[j]) { + found = true + } + }) + return found +} + +// HasRiver reports whether a river runs through a tile. +func (w *World) HasRiver(x, y int) bool { + return w.InBounds(x, y) && w.Rivers[w.Index(x, y)] != 0 +} + +// BlockBuildable reports whether every tile of a size x size block can be +// settled. +func (w *World) BlockBuildable(x, y, size int) bool { + for dx := 0; dx < size; dx++ { + for dy := 0; dy < size; dy++ { + if !w.Buildable(x+dx, y+dy) { + return false + } + } + } + return true +} + +// FindStart searches the whole map for the best place to seat a new player. +// +// Drawing random empty blocks and hoping one is habitable does not work: towns +// are seeded first and take the good land, so whatever is left unoccupied is +// disproportionately the water and mountain the town seeder rejected. A player +// picked that way reliably lands in a mountain range. Scanning is cheap — a few +// thousand blocks — so search properly instead. +// +// occupied reports whether a block of the given size at (x, y) collides with +// anything already placed. pick chooses among the shortlist. +func (w *World) FindStart(size int, occupied func(x, y int) bool, pick func(n int) int) (int, int, bool) { + type candidate struct{ x, y, score int } + candidates := make([]candidate, 0, 256) + best := 0 + + for y := 0; y+size <= w.Height; y++ { + for x := 0; x+size <= w.Width; x++ { + if occupied(x, y) { + continue + } + score := w.StartScore(x, y, size) + if score <= 0 { + continue + } + if score > best { + best = score + } + candidates = append(candidates, candidate{x, y, score}) + } + } + if len(candidates) == 0 { + return 0, 0, false + } + + // Shortlist everything close to the best rather than the single optimum, + // so consecutive registrations don't all land on the same tile. + threshold := best * 85 / 100 + shortlist := candidates[:0] + for _, c := range candidates { + if c.score >= threshold { + shortlist = append(shortlist, c) + } + } + c := shortlist[pick(len(shortlist))] + return c.x, c.y, true +} + +// StartScore rates a block as a starting location for a new player. A block +// with any unbuildable tile scores zero, so nobody is seated half in the sea or +// astride a mountain range; beyond that it rewards the things that make a Civ +// start worth having — fertile ground, fresh water and a coastline. +func (w *World) StartScore(x, y, size int) int { + if !w.BlockBuildable(x, y, size) { + return 0 + } + score := 1 + coastal, river := false, false + for dx := 0; dx < size; dx++ { + for dy := 0; dy < size; dy++ { + cx, cy := x+dx, y+dy + g, r, f := w.TerrainAt(cx, cy) + switch g { + case Grassland: + score += 3 + case Plains, Beach: + score += 2 + case Desert, Tundra: + score-- + } + if r == Hills { + score++ + } + switch f { + case Forest: + score++ + case Marsh: + score -= 2 + } + if w.HasRiver(cx, cy) { + river = true + } + if w.Coastal(cx, cy) { + coastal = true + } + } + } + if river { + score += 8 + } + if coastal { + score += 5 + } + return score +} diff --git a/internal/world/terrain.go b/internal/world/terrain.go new file mode 100644 index 0000000..3bc4128 --- /dev/null +++ b/internal/world/terrain.go @@ -0,0 +1,646 @@ +// Package world generates the game map: ground, landform and vegetation planes +// plus rivers and special resources. It is a pure package — no framework +// imports and no I/O — so setup, services and rpc can all depend on it the way +// they depend on domain. +package world + +import ( + "math" + "sort" +) + +// Terrain is the ground cover of a tile. +type Terrain uint8 + +const ( + // The zero value is unused: it exists so these line up exactly with the + // proto enums, whose STANDARD lint rules require an UNSPECIFIED zero. That + // lets the planes be copied to the wire byte-for-byte with no remapping. + TerrainUnspecified Terrain = iota + DeepOcean + Ocean + Coast + Lake + Beach + Grassland + Plains + Desert + Tundra + Snow +) + +// Relief is the landform, drawn over the ground. +type Relief uint8 + +const ( + ReliefUnspecified Relief = iota + Flat + Hills + Mountains +) + +// Feature is vegetation or surface cover, drawn over both. +type Feature uint8 + +const ( + // NoFeature is the zero value and doubles as the proto UNSPECIFIED: a tile + // with no feature specified simply has none. + NoFeature Feature = iota + Forest + Jungle + Marsh + Oasis + Ice +) + +// Special is a bonus resource marker. Purely decorative for now. +type Special uint8 + +const ( + // NoSpecial is the zero value and doubles as the proto UNSPECIFIED. + NoSpecial Special = iota + Wheat + Game + Furs + Fish + Whales + Coal + Iron + Gold + Gems +) + +// The world is three orthogonal planes rather than one flat list of biomes. +// Collapsed into one, forest-on-tundra and forest-on-plains would be separate +// values needing separate art; kept apart, a feature composites over any ground +// and the client's texture count stays small. +type World struct { + Width int + Height int + Seed int64 + + Terrain []uint8 + Relief []uint8 + Feature []uint8 + Special []uint8 + // Rivers holds a 6-bit mask per tile: bit i means the river continues + // toward neighbour i. Both tiles either side of a step carry the reciprocal + // bit, so each draws its own half and rivers occupy no tile of their own. + Rivers []uint8 + + elevation []float64 + moisture []float64 + temperature []float64 + levels levels +} + +type levels struct { + sea float64 + hill float64 + mountain float64 +} + +// Feature periods are in unit-hex widths (one hex is 2.0 across). +const ( + elevPeriod = 20.0 + ridgePeriod = 9.2 + ridgeWeight = 0.26 + moistPeriod = 25.0 + tempPeriod = 32.0 + forestPeriod = 8.4 + rimPeriod = 14.0 + warpAmp = 0.3 + + lacunarity = 2.0 + gain = 0.5 + + // landFraction is high for a Civ-style map on purpose. Towns are spread by + // Poisson-disk sampling across the whole grid, so every point of ocean is a + // candidate site rejected; a large continent with inland seas keeps the map + // densely settled without drowning half the towns. + landFraction = 0.70 + hillQuantile = 0.66 + mtnQuantile = 0.92 + lakeMaxTiles = 40 + + // Climate. Latitude is shaped by a cubic mix: a linear ramp buries both + // poles under about nine rows of ice. + poleMix = 0.6 + lapse = 0.3 + snowTemp = 0.11 + tundraTemp = 0.26 + iceTemp = 0.07 + + // Moisture cutoffs are quantiles of land, so each reads directly as a share + // of the continent: desert is the driest 22% of it. + desertMoist = 0.22 + desertTemp = 0.50 + plainsMoist = 0.52 + forestMoist = 0.42 + forestPatch = 0.52 + jungleMoist = 0.78 + jungleTemp = 0.72 + marshMoist = 0.88 +) + +func (w *World) Index(x, y int) int { return y*w.Width + x } + +func (w *World) InBounds(x, y int) bool { + return x >= 0 && y >= 0 && x < w.Width && y < w.Height +} + +// IsWater reports whether a tile is ocean, coast or lake. +func (w *World) IsWater(x, y int) bool { + return w.InBounds(x, y) && isWater(w.Terrain[w.Index(x, y)]) +} + +// Buildable reports whether a settlement can stand on a tile. Water, mountains +// and permanent ice are excluded. +func (w *World) Buildable(x, y int) bool { + if !w.InBounds(x, y) { + return false + } + i := w.Index(x, y) + return isLand(w.Terrain[i]) && Relief(w.Relief[i]) != Mountains && Terrain(w.Terrain[i]) != Snow +} + +// TerrainAt returns the three planes for a tile. +func (w *World) TerrainAt(x, y int) (Terrain, Relief, Feature) { + i := w.Index(x, y) + return Terrain(w.Terrain[i]), Relief(w.Relief[i]), Feature(w.Feature[i]) +} + +func isLand(t uint8) bool { return Terrain(t) >= Beach } +func isWater(t uint8) bool { return Terrain(t) >= DeepOcean && Terrain(t) <= Lake } + +// Generate builds a world deterministically from a seed. +func Generate(width, height int, seed int64) *World { + n := width * height + s := int(seed) + w := &World{ + Width: width, + Height: height, + Seed: seed, + Terrain: make([]uint8, n), + Relief: make([]uint8, n), + Feature: make([]uint8, n), + Special: make([]uint8, n), + Rivers: make([]uint8, n), + elevation: make([]float64, n), + moisture: make([]float64, n), + temperature: make([]float64, n), + } + + px := make([]float64, n) + py := make([]float64, n) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + px[y*width+x], py[y*width+x] = hexPoint(x, y) + } + } + + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + i := y*width + x + wx, wy := warp(px[i]/elevPeriod, py[i]/elevPeriod, s+11, warpAmp) + e := fbm(wx, wy, s+101, 6, 2.05, gain) + // Ridges only bite ground that is already high, so ranges rise out + // of the highlands instead of scarring the plains. + r := ridged(px[i]/ridgePeriod, py[i]/ridgePeriod, s+601, 4) + w.elevation[i] = (e + ridgeWeight*r*smoothstep((e-0.45)/0.35)) * w.rimFalloff(x, y, px[i], py[i], s) + } + } + + // Thresholds come from quantiles rather than fixed cutoffs, so retuning the + // noise can't accidentally flood or drown the entire world. + sea := quantile(w.elevation, 1-landFraction) + landElev := make([]float64, 0, n) + for i := 0; i < n; i++ { + if w.elevation[i] >= sea { + landElev = append(landElev, w.elevation[i]) + } + } + w.levels = levels{sea: sea, hill: quantile(landElev, hillQuantile), mountain: quantile(landElev, mtnQuantile)} + + distToWater := w.bfsDistance(func(i int) bool { return w.elevation[i] < sea }) + + // Moisture is rank-normalized so the biome cutoffs read as "the driest 22% + // of land". Ranking over land only matters: water carries the full coastal + // bonus, so including it shoves every land tile to the bottom. + raw := make([]float64, n) + for i := 0; i < n; i++ { + m := fbm(px[i]/moistPeriod, py[i]/moistPeriod, s+907, 4, lacunarity, gain) + if d := distToWater[i]; d >= 0 { + m += math.Max(0, 0.22-0.03*float64(d)) + } + raw[i] = m + } + w.moisture = rankNormalize(raw, func(i int) bool { return w.elevation[i] >= sea }) + + forestNoise := make([]float64, n) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + i := y*width + x + u := math.Abs(2*float64(y)/math.Max(1, float64(height-1)) - 1) + lat := 1 - (poleMix*u + (1-poleMix)*u*u*u) + jitter := (fbm(px[i]/tempPeriod, py[i]/tempPeriod, s+1777, 3, lacunarity, gain) - 0.5) * 0.18 + w.temperature[i] = clamp01(lat*1.05 + jitter - w.aboveSea(w.elevation[i])*lapse) + forestNoise[i] = fbm(px[i]/forestPeriod, py[i]/forestPeriod, s+2311, 3, lacunarity, gain) + } + } + + for i := 0; i < n; i++ { + if w.elevation[i] < sea { + w.Terrain[i] = uint8(Ocean) + w.Relief[i] = uint8(Flat) + } else { + w.Terrain[i] = uint8(w.classifyGround(w.elevation[i], w.moisture[i], w.temperature[i])) + w.Relief[i] = uint8(w.classifyRelief(w.elevation[i])) + } + w.Feature[i] = uint8(w.classifyFeature(Terrain(w.Terrain[i]), Relief(w.Relief[i]), w.elevation[i], w.moisture[i], w.temperature[i], forestNoise[i])) + } + + w.despeckle() + w.markLakes() + w.tierWater() + w.markBeaches() + w.smoothGround() + w.carveRivers(s) + w.placeSpecials(s) + return w +} + +func (w *World) aboveSea(e float64) float64 { + return clamp01((e - w.levels.sea) / math.Max(1e-6, 1-w.levels.sea)) +} + +// rimFalloff pulls the map's outer ring under sea level so the landmass is +// ringed by ocean. The width is noise-modulated; a constant one leaves a +// visibly rectangular coastline. +func (w *World) rimFalloff(x, y int, pxi, pyi float64, seed int) float64 { + d := x + for _, v := range []int{y, w.Width - 1 - x, w.Height - 1 - y} { + if v < d { + d = v + } + } + width := 2 + 6*fbm(pxi/rimPeriod, pyi/rimPeriod, seed+55, 2, lacunarity, gain) + return smoothstep(float64(d) / width) +} + +func (w *World) classifyGround(e, m, t float64) Terrain { + switch { + case t < snowTemp: + return Snow + case t < tundraTemp: + return Tundra + case m < desertMoist && t > desertTemp: + return Desert + case m < plainsMoist: + return Plains + default: + return Grassland + } +} + +func (w *World) classifyRelief(e float64) Relief { + switch { + case e >= w.levels.mountain: + return Mountains + case e >= w.levels.hill: + return Hills + default: + return Flat + } +} + +func (w *World) classifyFeature(ground Terrain, relief Relief, e, m, t, forestN float64) Feature { + if isWater(uint8(ground)) { + if t < iceTemp { + return Ice + } + return NoFeature + } + if relief == Mountains { + return NoFeature + } + if relief == Flat && m > marshMoist && w.aboveSea(e) < 0.14 { + return Marsh + } + if relief == Flat && t > jungleTemp && m > jungleMoist { + return Jungle + } + if ground == Snow { + return NoFeature + } + // A separate short-period field is what makes woodland form patches rather + // than tracing the moisture contour exactly. + if m > forestMoist && forestN > forestPatch && ground != Desert { + return Forest + } + return NoFeature +} + +// bfsDistance runs a multi-source breadth-first search over the hex grid, +// returning -1 where unreachable. +func (w *World) bfsDistance(isSource func(i int) bool) []int { + n := w.Width * w.Height + dist := make([]int, n) + queue := make([]int, 0, n) + for i := 0; i < n; i++ { + dist[i] = -1 + if isSource(i) { + dist[i] = 0 + queue = append(queue, i) + } + } + for qi := 0; qi < len(queue); qi++ { + i := queue[qi] + w.forEachNeighbor(i%w.Width, i/w.Width, func(j, _, _, _ int) { + if dist[j] != -1 { + return + } + dist[j] = dist[i] + 1 + queue = append(queue, j) + }) + } + return dist +} + +// despeckle removes one-tile islands and one-tile holes. +func (w *World) despeckle() { + for pass := 0; pass < 2; pass++ { + snap := make([]uint8, len(w.Terrain)) + copy(snap, w.Terrain) + for y := 0; y < w.Height; y++ { + for x := 0; x < w.Width; x++ { + i := y*w.Width + x + land, total := 0, 0 + counts := map[uint8]int{} + w.forEachNeighbor(x, y, func(j, _, _, _ int) { + total++ + if isLand(snap[j]) { + land++ + counts[snap[j]]++ + } + }) + switch { + case isLand(snap[i]) && land == 0: + w.Terrain[i] = uint8(Ocean) + w.Relief[i] = uint8(Flat) + w.Feature[i] = uint8(NoFeature) + case !isLand(snap[i]) && total >= 5 && land == total: + best, bestCount := uint8(Plains), -1 + for t, c := range counts { + // Iteration order over a Go map is randomised, so break + // ties on the terrain value to stay reproducible. + if c > bestCount || (c == bestCount && t < best) { + best, bestCount = t, c + } + } + w.Terrain[i] = best + } + } + } + } +} + +// markLakes turns small enclosed water bodies that never touch the map border +// into lakes. +func (w *World) markLakes() { + seen := make([]bool, len(w.Terrain)) + for y := 0; y < w.Height; y++ { + for x := 0; x < w.Width; x++ { + start := y*w.Width + x + if seen[start] || isLand(w.Terrain[start]) { + continue + } + comp := []int{start} + seen[start] = true + touchesBorder := false + for qi := 0; qi < len(comp); qi++ { + i := comp[qi] + cx, cy := i%w.Width, i/w.Width + if cx == 0 || cy == 0 || cx == w.Width-1 || cy == w.Height-1 { + touchesBorder = true + } + w.forEachNeighbor(cx, cy, func(j, _, _, _ int) { + if seen[j] || isLand(w.Terrain[j]) { + return + } + seen[j] = true + comp = append(comp, j) + }) + } + if !touchesBorder && len(comp) <= lakeMaxTiles { + for _, i := range comp { + w.Terrain[i] = uint8(Lake) + } + } + } + } +} + +// tierWater grades open water outward from the shore: coast, ocean, deep ocean. +func (w *World) tierWater() { + distToLand := w.bfsDistance(func(i int) bool { return isLand(w.Terrain[i]) }) + for i := range w.Terrain { + if Terrain(w.Terrain[i]) == Lake || isLand(w.Terrain[i]) { + continue + } + switch d := distToLand[i]; { + case d == 1: + w.Terrain[i] = uint8(Coast) + case d >= 4 || d == -1: + w.Terrain[i] = uint8(DeepOcean) + default: + w.Terrain[i] = uint8(Ocean) + } + } +} + +func (w *World) markBeaches() { + snap := make([]uint8, len(w.Terrain)) + copy(snap, w.Terrain) + for y := 0; y < w.Height; y++ { + for x := 0; x < w.Width; x++ { + i := y*w.Width + x + if Relief(w.Relief[i]) != Flat || Feature(w.Feature[i]) != NoFeature { + continue + } + t := Terrain(snap[i]) + if t != Grassland && t != Plains && t != Desert { + continue + } + if w.temperature[i] < 0.32 || w.aboveSea(w.elevation[i]) > 0.05 { + continue + } + coastal := false + w.forEachNeighbor(x, y, func(j, _, _, _ int) { + if Terrain(snap[j]) == Coast { + coastal = true + } + }) + if coastal { + w.Terrain[i] = uint8(Beach) + } + } + } +} + +// smoothGround runs one majority pass so biome edges read as regions rather +// than noise. +func (w *World) smoothGround() { + snap := make([]uint8, len(w.Terrain)) + copy(snap, w.Terrain) + for y := 0; y < w.Height; y++ { + for x := 0; x < w.Width; x++ { + i := y*w.Width + x + if isWater(snap[i]) || Terrain(snap[i]) == Beach { + continue + } + counts := map[uint8]int{} + total := 0 + w.forEachNeighbor(x, y, func(j, _, _, _ int) { + if isWater(snap[j]) { + return + } + total++ + counts[snap[j]]++ + }) + if total < 5 { + continue + } + for t, c := range counts { + if t != snap[i] && c >= 5 { + w.Terrain[i] = t + } + } + } + } +} + +// carveRivers walks rivers down the distance-to-water gradient, tie-broken by +// elevation. Pure steepest descent on noise strands most rivers in local +// minima; steering by distance-to-water guarantees they reach it. +func (w *World) carveRivers(seed int) { + distToWater := w.bfsDistance(func(i int) bool { return isWater(w.Terrain[i]) }) + + candidates := make([]int, 0, len(w.Terrain)) + for i := range w.Terrain { + if !isLand(w.Terrain[i]) || Terrain(w.Terrain[i]) == Snow { + continue + } + if w.moisture[i] < 0.45 || distToWater[i] < 3 { + continue + } + candidates = append(candidates, i) + } + // Highest ground first, so sources sit near watersheds. + sort.Slice(candidates, func(a, b int) bool { return w.elevation[candidates[a]] > w.elevation[candidates[b]] }) + + maxSources := (w.Width * w.Height) / 220 + if maxSources < 6 { + maxSources = 6 + } + const minSpacing = 6 + sources := make([]int, 0, maxSources) + for _, i := range candidates { + if len(sources) >= maxSources { + break + } + x, y := i%w.Width, i/w.Width + tooClose := false + for _, s := range sources { + sx, sy := s%w.Width, s/w.Width + if (sx-x)*(sx-x)+(sy-y)*(sy-y) < minSpacing*minSpacing { + tooClose = true + break + } + } + if !tooClose { + sources = append(sources, i) + } + } + + for _, start := range sources { + i := start + visited := map[int]bool{i: true} + for step := 0; step < 200; step++ { + x, y := i%w.Width, i/w.Width + bestJ, bestDir, bestD, bestE := -1, -1, 1<<30, math.Inf(1) + w.forEachNeighbor(x, y, func(j, dir, _, _ int) { + d := distToWater[j] + if d < 0 || visited[j] { + return + } + if d < bestD || (d == bestD && w.elevation[j] < bestE) { + bestD, bestE, bestJ, bestDir = d, w.elevation[j], j, dir + } + }) + if bestJ < 0 { + break + } + w.Rivers[i] |= 1 << uint(bestDir) + w.Rivers[bestJ] |= 1 << uint(opposite(bestDir)) + visited[bestJ] = true + if isWater(w.Terrain[bestJ]) { + break + } + i = bestJ + } + } +} + +type specialRule struct { + kind Special + chance float64 + ok func(g Terrain, r Relief, f Feature) bool +} + +var specialRules = []specialRule{ + {Gold, 1.0 / 30, func(g Terrain, r Relief, f Feature) bool { return r == Mountains }}, + {Gems, 1.0 / 45, func(g Terrain, r Relief, f Feature) bool { return f == Jungle }}, + {Coal, 1.0 / 38, func(g Terrain, r Relief, f Feature) bool { return r == Hills }}, + {Iron, 1.0 / 42, func(g Terrain, r Relief, f Feature) bool { return r == Hills || r == Mountains }}, + {Furs, 1.0 / 40, func(g Terrain, r Relief, f Feature) bool { return f == Forest && (g == Tundra || g == Snow) }}, + {Game, 1.0 / 45, func(g Terrain, r Relief, f Feature) bool { return f == Forest }}, + {Wheat, 1.0 / 55, func(g Terrain, r Relief, f Feature) bool { + return (g == Grassland || g == Plains) && r == Flat && f == NoFeature + }}, + {Fish, 1.0 / 50, func(g Terrain, r Relief, f Feature) bool { return g == Coast }}, + {Whales, 1.0 / 80, func(g Terrain, r Relief, f Feature) bool { return g == Ocean }}, +} + +// placeSpecials makes one deterministic row-major pass. Rejecting a tile whose +// neighbour already carries a special keeps resources spread out; the fixed +// scan order is what makes that rule reproducible. +func (w *World) placeSpecials(seed int) { + for y := 0; y < w.Height; y++ { + for x := 0; x < w.Width; x++ { + i := y*w.Width + x + crowded := false + w.forEachNeighbor(x, y, func(j, _, _, _ int) { + if w.Special[j] != 0 { + crowded = true + } + }) + if crowded { + continue + } + roll := hash01(x, y, seed+4242) + acc := 0.0 + g, r, f := Terrain(w.Terrain[i]), Relief(w.Relief[i]), Feature(w.Feature[i]) + for _, rule := range specialRules { + if !rule.ok(g, r, f) { + continue + } + acc += rule.chance + if roll < acc { + w.Special[i] = uint8(rule.kind) + break + } + } + } + } +} diff --git a/proto/cityio/entity/v1/terrain.proto b/proto/cityio/entity/v1/terrain.proto new file mode 100644 index 0000000..dcd1dff --- /dev/null +++ b/proto/cityio/entity/v1/terrain.proto @@ -0,0 +1,61 @@ +syntax = "proto3"; + +package cityio.entity.v1; + +// The map is described by three orthogonal planes rather than one flat list of +// biomes. Collapsed into one, forest-on-tundra and forest-on-plains would be +// separate values needing separate art; kept apart, a feature composites over +// any ground and the client's texture count stays small. +// +// Every plane is transmitted as one byte per tile holding these enum values, so +// the numbering here is the wire format — inserting a value in the middle +// silently reinterprets every existing tile. + +// TerrainType is the ground cover of a tile. +enum TerrainType { + TERRAIN_TYPE_UNSPECIFIED = 0; + TERRAIN_TYPE_DEEP_OCEAN = 1; + TERRAIN_TYPE_OCEAN = 2; + TERRAIN_TYPE_COAST = 3; + TERRAIN_TYPE_LAKE = 4; + TERRAIN_TYPE_BEACH = 5; + TERRAIN_TYPE_GRASSLAND = 6; + TERRAIN_TYPE_PLAINS = 7; + TERRAIN_TYPE_DESERT = 8; + TERRAIN_TYPE_TUNDRA = 9; + TERRAIN_TYPE_SNOW = 10; +} + +// ReliefType is the landform, drawn over the ground. +enum ReliefType { + RELIEF_TYPE_UNSPECIFIED = 0; + RELIEF_TYPE_FLAT = 1; + RELIEF_TYPE_HILLS = 2; + RELIEF_TYPE_MOUNTAINS = 3; +} + +// FeatureType is vegetation or surface cover, drawn over both. The unspecified +// zero doubles as "no feature": a tile with none specified simply has none. +enum FeatureType { + FEATURE_TYPE_UNSPECIFIED = 0; + FEATURE_TYPE_FOREST = 1; + FEATURE_TYPE_JUNGLE = 2; + FEATURE_TYPE_MARSH = 3; + FEATURE_TYPE_OASIS = 4; + FEATURE_TYPE_ICE = 5; +} + +// SpecialType marks a bonus resource. Decorative for now — terrain has no +// effect on yields. The unspecified zero doubles as "no resource". +enum SpecialType { + SPECIAL_TYPE_UNSPECIFIED = 0; + SPECIAL_TYPE_WHEAT = 1; + SPECIAL_TYPE_GAME = 2; + SPECIAL_TYPE_FURS = 3; + SPECIAL_TYPE_FISH = 4; + SPECIAL_TYPE_WHALES = 5; + SPECIAL_TYPE_COAL = 6; + SPECIAL_TYPE_IRON = 7; + SPECIAL_TYPE_GOLD = 8; + SPECIAL_TYPE_GEMS = 9; +} diff --git a/proto/cityio/service/v1/map.proto b/proto/cityio/service/v1/map.proto index 803fe1f..f477505 100644 --- a/proto/cityio/service/v1/map.proto +++ b/proto/cityio/service/v1/map.proto @@ -29,8 +29,34 @@ message GetTileResponse { Tile tile = 1; } +message GetTerrainRequest {} + +// GetTerrainResponse carries the whole map in one call. +// +// Each plane is packed one byte per tile in row-major order, so the value for +// (x, y) is at index y * width + x. At the current map size that is about 5 KB +// per plane — small enough that chunking or viewport queries would cost more +// than they save, and it lets the client cache the world for the session. +// +// Terrain is generated by the server from `seed` and does not change, so this +// response is stable for the lifetime of a world. +message GetTerrainResponse { + int32 width = 1; + int32 height = 2; + int64 seed = 3; + bytes terrain = 4; // cityio.entity.v1.TerrainType per tile + bytes relief = 5; // cityio.entity.v1.ReliefType per tile + bytes feature = 6; // cityio.entity.v1.FeatureType per tile + bytes special = 7; // cityio.entity.v1.SpecialType per tile + // rivers holds a 6-bit mask per tile: bit i means a river continues toward + // neighbour i. Both tiles either side of a step carry the reciprocal bit, so + // each renders its own half and rivers occupy no tile of their own. + bytes rivers = 8; +} + // MapService serves world snapshots read from the persistence layer. service MapService { rpc GetMap(GetMapRequest) returns (GetMapResponse); rpc GetTile(GetTileRequest) returns (GetTileResponse); + rpc GetTerrain(GetTerrainRequest) returns (GetTerrainResponse); }