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); }