diff --git a/internal/domain/visibility.go b/internal/domain/visibility.go index 245735c..29c9c58 100644 --- a/internal/domain/visibility.go +++ b/internal/domain/visibility.go @@ -1,75 +1,125 @@ package domain -// PointVisible reports whether (px, py) is within Chebyshev distance radius -// of any tile belonging to any of the given cities. -func PointVisible(cities []City, px, py, radius int) bool { +// Watcher is an axis-aligned box that grants vision to whoever owns it. A city +// watches its whole footprint; an army watches the single tile it stands on. +// +// Vision used to be computed from owned cities alone. That left armies able to +// march anywhere without revealing a thing, and — more importantly — meant a +// player's visible area never changed after founding, so there was nothing to +// explore and no way to learn the shape of the map. +type Watcher struct { + X, Y int + W, H int +} + +func CityWatcher(c City) Watcher { + return Watcher{X: c.StartX, Y: c.StartY, W: c.Size, H: c.Size} +} + +func ArmyWatcher(a Army) Watcher { + return Watcher{X: a.X, Y: a.Y, W: 1, H: 1} +} + +// WatchersFor builds a player's vision set from everything they own. +func WatchersFor(cities []City, armies []Army) []Watcher { + ws := make([]Watcher, 0, len(cities)+len(armies)) for i := range cities { - c := &cities[i] - dx := max(0, c.StartX-px, px-(c.StartX+c.Size-1)) - dy := max(0, c.StartY-py, py-(c.StartY+c.Size-1)) - if max(dx, dy) <= radius { + ws = append(ws, CityWatcher(cities[i])) + } + for i := range armies { + ws = append(ws, ArmyWatcher(armies[i])) + } + return ws +} + +// DistanceTo returns the Chebyshev (king-move) distance from a point to this +// box. Zero when the point lies inside it. +func (w Watcher) DistanceTo(px, py int) int { + dx := max(0, w.X-px, px-(w.X+w.W-1)) + dy := max(0, w.Y-py, py-(w.Y+w.H-1)) + return max(dx, dy) +} + +// PointVisible reports whether (px, py) is within radius of any watcher. +func PointVisible(ws []Watcher, px, py, radius int) bool { + for _, w := range ws { + if w.DistanceTo(px, py) <= radius { return true } } return false } -// CityVisible reports whether any tile of target falls within Chebyshev -// distance radius of any tile in any of the given cities. Uses AABB overlap: -// expand each city box by radius and check intersection with the target box. -func CityVisible(cities []City, target City, radius int) bool { - tx1, ty1 := target.StartX, target.StartY - tx2, ty2 := target.StartX+target.Size-1, target.StartY+target.Size-1 - for i := range cities { - c := &cities[i] - ox1 := c.StartX - radius - oy1 := c.StartY - radius - ox2 := c.StartX + c.Size - 1 + radius - oy2 := c.StartY + c.Size - 1 + radius - if ox1 <= tx2 && ox2 >= tx1 && oy1 <= ty2 && oy2 >= ty1 { +// BoxVisible reports whether any tile of target falls within radius of any +// watcher. Uses AABB overlap: expand each watcher by radius and intersect. +func BoxVisible(ws []Watcher, target Watcher, radius int) bool { + tx2, ty2 := target.X+target.W-1, target.Y+target.H-1 + for _, w := range ws { + if w.X-radius <= tx2 && w.X+w.W-1+radius >= target.X && + w.Y-radius <= ty2 && w.Y+w.H-1+radius >= target.Y { return true } } return false } -// FilterCities returns the subset of all visible from cities within radius. -func FilterCities(cities, all []City, radius int) []City { +// CityVisible reports whether any tile of target is visible. +func CityVisible(ws []Watcher, target City, radius int) bool { + return BoxVisible(ws, CityWatcher(target), radius) +} + +// FilterCities returns the subset of all visible to the given watchers. +func FilterCities(ws []Watcher, all []City, radius int) []City { out := make([]City, 0, len(all)) for _, c := range all { - if CityVisible(cities, c, radius) { + if CityVisible(ws, c, radius) { out = append(out, c) } } return out } -// FilterBuildings returns the subset of buildings visible from cities within radius. -func FilterBuildings(cities []City, all []Building, radius int) []Building { +// FilterBuildings returns the subset of buildings visible to the given watchers. +func FilterBuildings(ws []Watcher, all []Building, radius int) []Building { out := make([]Building, 0, len(all)) for _, b := range all { - if PointVisible(cities, b.X, b.Y, radius) { + if PointVisible(ws, b.X, b.Y, radius) { out = append(out, b) } } return out } -// FilterArmies returns the subset of armies visible from cities within radius. -func FilterArmies(cities []City, all []Army, radius int) []Army { +// FilterArmies returns the subset of armies visible to the given watchers. +func FilterArmies(ws []Watcher, all []Army, radius int) []Army { out := make([]Army, 0, len(all)) for _, a := range all { - if PointVisible(cities, a.X, a.Y, radius) { + if PointVisible(ws, a.X, a.Y, radius) { out = append(out, a) } } return out } +// EachVisibleTile calls fn for every in-bounds tile within radius of any +// watcher, possibly more than once where watchers overlap. Walks each watcher's +// own neighbourhood rather than scanning the map, so the cost is proportional +// to what a player can see rather than to the size of the world. +func EachVisibleTile(ws []Watcher, radius, width, height int, fn func(x, y int)) { + for _, w := range ws { + for y := w.Y - radius; y < w.Y+w.H+radius; y++ { + for x := w.X - radius; x < w.X+w.W+radius; x++ { + if x < 0 || y < 0 || x >= width || y >= height { + continue + } + fn(x, y) + } + } + } +} + // ChebyshevToCity returns the Chebyshev (king-move) distance from point (px, py) // to the nearest tile of city c. Zero when the point lies inside the city box. func ChebyshevToCity(c City, px, py int) int { - dx := max(0, c.StartX-px, px-(c.StartX+c.Size-1)) - dy := max(0, c.StartY-py, py-(c.StartY+c.Size-1)) - return max(dx, dy) + return CityWatcher(c).DistanceTo(px, py) } diff --git a/internal/gen/cityio/service/v1/map.pb.go b/internal/gen/cityio/service/v1/map.pb.go index 9490494..84ab6e3 100644 --- a/internal/gen/cityio/service/v1/map.pb.go +++ b/internal/gen/cityio/service/v1/map.pb.go @@ -319,46 +319,45 @@ func (*GetTerrainRequest) Descriptor() ([]byte, []int) { return file_cityio_service_v1_map_proto_rawDescGZIP(), []int{5} } -// GetTerrainResponse carries the whole map in one call. +// VisibleTerrain is the ground a player can see right now. // -// 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. +// Vision is ephemeral: it comes from cities, from armies while they stand +// somewhere, and from structures held against an enemy. Tiles therefore leave +// this set as often as they enter it, so it is always sent whole rather than as +// a reveal-only delta — replacing the set outright is the only way client and +// server cannot disagree about what is still lit. // -// 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 { +// Tiles are addressed by row-major index (y * width + x). Each plane holds one +// byte per entry of `indices`, in the same order. +type VisibleTerrain 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 + Indices []int32 `protobuf:"varint,1,rep,packed,name=indices,proto3" json:"indices,omitempty"` + Terrain []byte `protobuf:"bytes,2,opt,name=terrain,proto3" json:"terrain,omitempty"` // cityio.entity.v1.TerrainType + Relief []byte `protobuf:"bytes,3,opt,name=relief,proto3" json:"relief,omitempty"` // cityio.entity.v1.ReliefType + Feature []byte `protobuf:"bytes,4,opt,name=feature,proto3" json:"feature,omitempty"` // cityio.entity.v1.FeatureType + Special []byte `protobuf:"bytes,5,opt,name=special,proto3" json:"special,omitempty"` // cityio.entity.v1.SpecialType // 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"` + Rivers []byte `protobuf:"bytes,6,opt,name=rivers,proto3" json:"rivers,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *GetTerrainResponse) Reset() { - *x = GetTerrainResponse{} +func (x *VisibleTerrain) Reset() { + *x = VisibleTerrain{} mi := &file_cityio_service_v1_map_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *GetTerrainResponse) String() string { +func (x *VisibleTerrain) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GetTerrainResponse) ProtoMessage() {} +func (*VisibleTerrain) ProtoMessage() {} -func (x *GetTerrainResponse) ProtoReflect() protoreflect.Message { +func (x *VisibleTerrain) ProtoReflect() protoreflect.Message { mi := &file_cityio_service_v1_map_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -370,63 +369,120 @@ func (x *GetTerrainResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use GetTerrainResponse.ProtoReflect.Descriptor instead. -func (*GetTerrainResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use VisibleTerrain.ProtoReflect.Descriptor instead. +func (*VisibleTerrain) Descriptor() ([]byte, []int) { return file_cityio_service_v1_map_proto_rawDescGZIP(), []int{6} } -func (x *GetTerrainResponse) GetWidth() int32 { +func (x *VisibleTerrain) GetIndices() []int32 { if x != nil { - return x.Width + return x.Indices } - return 0 + return nil } -func (x *GetTerrainResponse) GetHeight() int32 { +func (x *VisibleTerrain) GetTerrain() []byte { if x != nil { - return x.Height + return x.Terrain } - return 0 + return nil } -func (x *GetTerrainResponse) GetSeed() int64 { +func (x *VisibleTerrain) GetRelief() []byte { if x != nil { - return x.Seed + return x.Relief } - return 0 + return nil } -func (x *GetTerrainResponse) GetTerrain() []byte { +func (x *VisibleTerrain) GetFeature() []byte { if x != nil { - return x.Terrain + return x.Feature } return nil } -func (x *GetTerrainResponse) GetRelief() []byte { +func (x *VisibleTerrain) GetSpecial() []byte { if x != nil { - return x.Relief + return x.Special } return nil } -func (x *GetTerrainResponse) GetFeature() []byte { +func (x *VisibleTerrain) GetRivers() []byte { if x != nil { - return x.Feature + return x.Rivers } return nil } -func (x *GetTerrainResponse) GetSpecial() []byte { +// GetTerrainResponse bootstraps a client with the map's dimensions and whatever +// ground it can currently see. Everything else is unknown, and stays unknown +// until something of the player's is watching it. +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"` + Visible *VisibleTerrain `protobuf:"bytes,4,opt,name=visible,proto3" json:"visible,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTerrainResponse) Reset() { + *x = GetTerrainResponse{} + mi := &file_cityio_service_v1_map_proto_msgTypes[7] + 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[7] if x != nil { - return x.Special + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return nil + return mi.MessageOf(x) +} + +// Deprecated: Use GetTerrainResponse.ProtoReflect.Descriptor instead. +func (*GetTerrainResponse) Descriptor() ([]byte, []int) { + return file_cityio_service_v1_map_proto_rawDescGZIP(), []int{7} } -func (x *GetTerrainResponse) GetRivers() []byte { +func (x *GetTerrainResponse) GetWidth() int32 { if x != nil { - return x.Rivers + 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) GetVisible() *VisibleTerrain { + if x != nil { + return x.Visible } return nil } @@ -455,16 +511,19 @@ const file_cityio_service_v1_map_proto_rawDesc = "" + "\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\x04tile\"\x13\n" + - "\x11GetTerrainRequest\"\xd4\x01\n" + + "\x11GetTerrainRequest\"\xa8\x01\n" + + "\x0eVisibleTerrain\x12\x18\n" + + "\aindices\x18\x01 \x03(\x05R\aindices\x12\x18\n" + + "\aterrain\x18\x02 \x01(\fR\aterrain\x12\x16\n" + + "\x06relief\x18\x03 \x01(\fR\x06relief\x12\x18\n" + + "\afeature\x18\x04 \x01(\fR\afeature\x12\x18\n" + + "\aspecial\x18\x05 \x01(\fR\aspecial\x12\x16\n" + + "\x06rivers\x18\x06 \x01(\fR\x06rivers\"\x93\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" + + "\x04seed\x18\x03 \x01(\x03R\x04seed\x12;\n" + + "\avisible\x18\x04 \x01(\v2!.cityio.service.v1.VisibleTerrainR\avisible2\x88\x02\n" + "\n" + "MapService\x12M\n" + "\x06GetMap\x12 .cityio.service.v1.GetMapRequest\x1a!.cityio.service.v1.GetMapResponse\x12P\n" + @@ -485,7 +544,7 @@ 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, 7) +var file_cityio_service_v1_map_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_cityio_service_v1_map_proto_goTypes = []any{ (*GetMapRequest)(nil), // 0: cityio.service.v1.GetMapRequest (*GetMapResponse)(nil), // 1: cityio.service.v1.GetMapResponse @@ -493,33 +552,35 @@ var file_cityio_service_v1_map_proto_goTypes = []any{ (*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 + (*VisibleTerrain)(nil), // 6: cityio.service.v1.VisibleTerrain + (*GetTerrainResponse)(nil), // 7: cityio.service.v1.GetTerrainResponse + (*v1.CityId)(nil), // 8: cityio.entity.v1.CityId + (*v1.BuildingId)(nil), // 9: cityio.entity.v1.BuildingId + (*v1.EntityBag)(nil), // 10: cityio.entity.v1.EntityBag + (*v1.ArmyId)(nil), // 11: cityio.entity.v1.ArmyId + (*v1.Coordinates)(nil), // 12: cityio.entity.v1.Coordinates } var file_cityio_service_v1_map_proto_depIdxs = []int32{ - 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 + 8, // 0: cityio.service.v1.GetMapResponse.city_ids:type_name -> cityio.entity.v1.CityId + 9, // 1: cityio.service.v1.GetMapResponse.building_ids:type_name -> cityio.entity.v1.BuildingId + 10, // 2: cityio.service.v1.GetMapResponse.entities:type_name -> cityio.entity.v1.EntityBag + 8, // 3: cityio.service.v1.Tile.city_id:type_name -> cityio.entity.v1.CityId + 9, // 4: cityio.service.v1.Tile.building_id:type_name -> cityio.entity.v1.BuildingId + 11, // 5: cityio.service.v1.Tile.army_ids:type_name -> cityio.entity.v1.ArmyId + 12, // 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 - 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 + 6, // 8: cityio.service.v1.GetTerrainResponse.visible:type_name -> cityio.service.v1.VisibleTerrain + 0, // 9: cityio.service.v1.MapService.GetMap:input_type -> cityio.service.v1.GetMapRequest + 3, // 10: cityio.service.v1.MapService.GetTile:input_type -> cityio.service.v1.GetTileRequest + 5, // 11: cityio.service.v1.MapService.GetTerrain:input_type -> cityio.service.v1.GetTerrainRequest + 1, // 12: cityio.service.v1.MapService.GetMap:output_type -> cityio.service.v1.GetMapResponse + 4, // 13: cityio.service.v1.MapService.GetTile:output_type -> cityio.service.v1.GetTileResponse + 7, // 14: cityio.service.v1.MapService.GetTerrain:output_type -> cityio.service.v1.GetTerrainResponse + 12, // [12:15] is the sub-list for method output_type + 9, // [9:12] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_cityio_service_v1_map_proto_init() } @@ -534,7 +595,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: 7, + NumMessages: 8, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/gen/cityio/service/v1/user.pb.go b/internal/gen/cityio/service/v1/user.pb.go index f195e1f..9c9e196 100644 --- a/internal/gen/cityio/service/v1/user.pb.go +++ b/internal/gen/cityio/service/v1/user.pb.go @@ -444,10 +444,14 @@ func (*StreamStateRequest) Descriptor() ([]byte, []int) { // StreamStateResponse wraps an EntityBag pushed to a client whenever state changes. type StreamStateResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Entities *v1.EntityBag `protobuf:"bytes,1,opt,name=entities,proto3" json:"entities,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Entities *v1.EntityBag `protobuf:"bytes,1,opt,name=entities,proto3" json:"entities,omitempty"` + // The player's currently visible ground, sent whole whenever it changes and + // omitted when it has not. Absent on most ticks, since vision only moves when + // a city, army or watchtower does. + VisibleTerrain *VisibleTerrain `protobuf:"bytes,2,opt,name=visible_terrain,json=visibleTerrain,proto3,oneof" json:"visible_terrain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StreamStateResponse) Reset() { @@ -487,11 +491,18 @@ func (x *StreamStateResponse) GetEntities() *v1.EntityBag { return nil } +func (x *StreamStateResponse) GetVisibleTerrain() *VisibleTerrain { + if x != nil { + return x.VisibleTerrain + } + return nil +} + var File_cityio_service_v1_user_proto protoreflect.FileDescriptor const file_cityio_service_v1_user_proto_rawDesc = "" + "\n" + - "\x1ccityio/service/v1/user.proto\x12\x11cityio.service.v1\x1a\x1dcityio/entity/v1/common.proto\x1a\x1bcityio/entity/v1/user.proto\x1a\x1acityio/entity/v1/bag.proto\"_\n" + + "\x1ccityio/service/v1/user.proto\x12\x11cityio.service.v1\x1a\x1dcityio/entity/v1/common.proto\x1a\x1bcityio/entity/v1/user.proto\x1a\x1acityio/entity/v1/bag.proto\x1a\x1bcityio/service/v1/map.proto\"_\n" + "\x0fRegisterRequest\x12\x14\n" + "\x05email\x18\x01 \x01(\tR\x05email\x12\x1a\n" + "\busername\x18\x02 \x01(\tR\busername\x12\x1a\n" + @@ -514,9 +525,11 @@ const file_cityio_service_v1_user_proto_rawDesc = "" + "\x11DeleteUserRequest\x121\n" + "\auser_id\x18\x01 \x01(\v2\x18.cityio.entity.v1.UserIdR\x06userId\"\x14\n" + "\x12DeleteUserResponse\"\x14\n" + - "\x12StreamStateRequest\"N\n" + + "\x12StreamStateRequest\"\xb3\x01\n" + "\x13StreamStateResponse\x127\n" + - "\bentities\x18\x01 \x01(\v2\x1b.cityio.entity.v1.EntityBagR\bentities2\xbb\x03\n" + + "\bentities\x18\x01 \x01(\v2\x1b.cityio.entity.v1.EntityBagR\bentities\x12O\n" + + "\x0fvisible_terrain\x18\x02 \x01(\v2!.cityio.service.v1.VisibleTerrainH\x00R\x0evisibleTerrain\x88\x01\x01B\x12\n" + + "\x10_visible_terrain2\xbb\x03\n" + "\vUserService\x12S\n" + "\bRegister\x12\".cityio.service.v1.RegisterRequest\x1a#.cityio.service.v1.RegisterResponse\x12J\n" + "\x05Login\x12\x1f.cityio.service.v1.LoginRequest\x1a .cityio.service.v1.LoginResponse\x12P\n" + @@ -553,6 +566,7 @@ var file_cityio_service_v1_user_proto_goTypes = []any{ (*v1.UserId)(nil), // 10: cityio.entity.v1.UserId (*v1.User)(nil), // 11: cityio.entity.v1.User (*v1.EntityBag)(nil), // 12: cityio.entity.v1.EntityBag + (*VisibleTerrain)(nil), // 13: cityio.service.v1.VisibleTerrain } var file_cityio_service_v1_user_proto_depIdxs = []int32{ 10, // 0: cityio.service.v1.RegisterResponse.user_id:type_name -> cityio.entity.v1.UserId @@ -561,21 +575,22 @@ var file_cityio_service_v1_user_proto_depIdxs = []int32{ 11, // 3: cityio.service.v1.GetUserResponse.user:type_name -> cityio.entity.v1.User 10, // 4: cityio.service.v1.DeleteUserRequest.user_id:type_name -> cityio.entity.v1.UserId 12, // 5: cityio.service.v1.StreamStateResponse.entities:type_name -> cityio.entity.v1.EntityBag - 0, // 6: cityio.service.v1.UserService.Register:input_type -> cityio.service.v1.RegisterRequest - 2, // 7: cityio.service.v1.UserService.Login:input_type -> cityio.service.v1.LoginRequest - 4, // 8: cityio.service.v1.UserService.GetUser:input_type -> cityio.service.v1.GetUserRequest - 6, // 9: cityio.service.v1.UserService.DeleteUser:input_type -> cityio.service.v1.DeleteUserRequest - 8, // 10: cityio.service.v1.UserService.StreamState:input_type -> cityio.service.v1.StreamStateRequest - 1, // 11: cityio.service.v1.UserService.Register:output_type -> cityio.service.v1.RegisterResponse - 3, // 12: cityio.service.v1.UserService.Login:output_type -> cityio.service.v1.LoginResponse - 5, // 13: cityio.service.v1.UserService.GetUser:output_type -> cityio.service.v1.GetUserResponse - 7, // 14: cityio.service.v1.UserService.DeleteUser:output_type -> cityio.service.v1.DeleteUserResponse - 9, // 15: cityio.service.v1.UserService.StreamState:output_type -> cityio.service.v1.StreamStateResponse - 11, // [11:16] is the sub-list for method output_type - 6, // [6:11] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 13, // 6: cityio.service.v1.StreamStateResponse.visible_terrain:type_name -> cityio.service.v1.VisibleTerrain + 0, // 7: cityio.service.v1.UserService.Register:input_type -> cityio.service.v1.RegisterRequest + 2, // 8: cityio.service.v1.UserService.Login:input_type -> cityio.service.v1.LoginRequest + 4, // 9: cityio.service.v1.UserService.GetUser:input_type -> cityio.service.v1.GetUserRequest + 6, // 10: cityio.service.v1.UserService.DeleteUser:input_type -> cityio.service.v1.DeleteUserRequest + 8, // 11: cityio.service.v1.UserService.StreamState:input_type -> cityio.service.v1.StreamStateRequest + 1, // 12: cityio.service.v1.UserService.Register:output_type -> cityio.service.v1.RegisterResponse + 3, // 13: cityio.service.v1.UserService.Login:output_type -> cityio.service.v1.LoginResponse + 5, // 14: cityio.service.v1.UserService.GetUser:output_type -> cityio.service.v1.GetUserResponse + 7, // 15: cityio.service.v1.UserService.DeleteUser:output_type -> cityio.service.v1.DeleteUserResponse + 9, // 16: cityio.service.v1.UserService.StreamState:output_type -> cityio.service.v1.StreamStateResponse + 12, // [12:17] is the sub-list for method output_type + 7, // [7:12] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_cityio_service_v1_user_proto_init() } @@ -583,6 +598,8 @@ func file_cityio_service_v1_user_proto_init() { if File_cityio_service_v1_user_proto != nil { return } + file_cityio_service_v1_map_proto_init() + file_cityio_service_v1_user_proto_msgTypes[9].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/internal/mapping/terrain.go b/internal/mapping/terrain.go new file mode 100644 index 0000000..78386a8 --- /dev/null +++ b/internal/mapping/terrain.go @@ -0,0 +1,68 @@ +package mapping + +import ( + "sort" + + "cityio/internal/domain" + servicev1 "cityio/internal/gen/cityio/service/v1" + "cityio/internal/world" +) + +// VisibleTerrainToProto packs the ground currently visible to ws. +// +// Watchers overlap constantly — a city and the army garrisoned beside it cover +// much the same tiles — so hits are deduplicated before packing. Indices are +// sorted so two calls with the same vision produce byte-identical output, which +// is what lets the stream cheaply decide whether anything actually changed. +func VisibleTerrainToProto(gameWorld *world.World, ws []domain.Watcher, radius int) *servicev1.VisibleTerrain { + if gameWorld == nil { + return nil + } + + seen := make([]bool, gameWorld.Width*gameWorld.Height) + indices := make([]int32, 0, 256) + domain.EachVisibleTile(ws, radius, gameWorld.Width, gameWorld.Height, func(x, y int) { + i := y*gameWorld.Width + x + if seen[i] { + return + } + seen[i] = true + indices = append(indices, int32(i)) + }) + sort.Slice(indices, func(a, b int) bool { return indices[a] < indices[b] }) + + out := &servicev1.VisibleTerrain{ + Indices: indices, + Terrain: make([]byte, len(indices)), + Relief: make([]byte, len(indices)), + Feature: make([]byte, len(indices)), + Special: make([]byte, len(indices)), + Rivers: make([]byte, len(indices)), + } + for k, idx := range indices { + out.Terrain[k] = gameWorld.Terrain[idx] + out.Relief[k] = gameWorld.Relief[idx] + out.Feature[k] = gameWorld.Feature[idx] + out.Special[k] = gameWorld.Special[idx] + out.Rivers[k] = gameWorld.Rivers[idx] + } + return out +} + +// SameVisibleTerrain reports whether two packed sets cover the same tiles. +// Only the indices are compared: terrain itself never changes, so identical +// coverage means identical content. +func SameVisibleTerrain(a, b *servicev1.VisibleTerrain) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + if len(a.GetIndices()) != len(b.GetIndices()) { + return false + } + for i, v := range a.GetIndices() { + if b.GetIndices()[i] != v { + return false + } + } + return true +} diff --git a/internal/rpc/army.go b/internal/rpc/army.go index 567aa03..8e8dd54 100644 --- a/internal/rpc/army.go +++ b/internal/rpc/army.go @@ -111,11 +111,11 @@ func (h *armyHandler) GetArmy(ctx context.Context, req *connect.Request[servicev // its tile. claims, _ := auth.ClaimsFromContext(ctx) if army.Owner != claims.UserID { - owned, err := h.srv.ownedCities(ctx) + seen, err := h.srv.watchers(ctx) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } - if !domain.PointVisible(owned, army.X, army.Y, constants.VisionRadius) { + if !domain.PointVisible(seen, army.X, army.Y, constants.VisionRadius) { return nil, connect.NewError(connect.CodeNotFound, errors.New("army not found")) } } diff --git a/internal/rpc/building.go b/internal/rpc/building.go index 63a3503..2a52fab 100644 --- a/internal/rpc/building.go +++ b/internal/rpc/building.go @@ -69,11 +69,11 @@ func (h *buildingHandler) GetBuilding(ctx context.Context, req *connect.Request[ return nil, connect.NewError(connect.CodeNotFound, errors.New("building not found")) } - owned, err := h.srv.ownedCities(ctx) + seen, err := h.srv.watchers(ctx) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } - if !domain.PointVisible(owned, resp.Building.X, resp.Building.Y, constants.VisionRadius) { + if !domain.PointVisible(seen, resp.Building.X, resp.Building.Y, constants.VisionRadius) { return nil, connect.NewError(connect.CodeNotFound, errors.New("building not found")) } @@ -128,11 +128,11 @@ func (h *buildingHandler) ListBuildings(ctx context.Context, req *connect.Reques return nil, connect.NewError(connect.CodeInternal, err) } - owned, err := h.srv.ownedCities(ctx) + seen, err := h.srv.watchers(ctx) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } - buildingList = domain.FilterBuildings(owned, buildingList, constants.VisionRadius) + buildingList = domain.FilterBuildings(seen, buildingList, constants.VisionRadius) buildings := make([]*entityv1.Building, 0, len(buildingList)) for _, b := range buildingList { diff --git a/internal/rpc/city.go b/internal/rpc/city.go index 9881d0e..76a9fd9 100644 --- a/internal/rpc/city.go +++ b/internal/rpc/city.go @@ -30,11 +30,11 @@ func (h *cityHandler) GetCity(ctx context.Context, req *connect.Request[servicev return nil, connect.NewError(connect.CodeNotFound, errors.New("city not found")) } - owned, err := h.srv.ownedCities(ctx) + seen, err := h.srv.watchers(ctx) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } - if !domain.CityVisible(owned, resp.City, constants.VisionRadius) { + if !domain.CityVisible(seen, resp.City, constants.VisionRadius) { return nil, connect.NewError(connect.CodeNotFound, errors.New("city not found")) } diff --git a/internal/rpc/map.go b/internal/rpc/map.go index 06f332a..ca97732 100644 --- a/internal/rpc/map.go +++ b/internal/rpc/map.go @@ -21,7 +21,7 @@ type mapHandler struct { } func (h *mapHandler) GetMap(ctx context.Context, req *connect.Request[servicev1.GetMapRequest]) (*connect.Response[servicev1.GetMapResponse], error) { - owned, err := h.srv.ownedCities(ctx) + seen, err := h.srv.watchers(ctx) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } @@ -39,9 +39,9 @@ func (h *mapHandler) GetMap(ctx context.Context, req *connect.Request[servicev1. return nil, connect.NewError(connect.CodeInternal, err) } - cityList = domain.FilterCities(owned, cityList, constants.VisionRadius) - buildingList = domain.FilterBuildings(owned, buildingList, constants.VisionRadius) - armyList = domain.FilterArmies(owned, armyList, constants.VisionRadius) + cityList = domain.FilterCities(seen, cityList, constants.VisionRadius) + buildingList = domain.FilterBuildings(seen, buildingList, constants.VisionRadius) + armyList = domain.FilterArmies(seen, armyList, constants.VisionRadius) cityIds := make([]*entityv1.CityId, 0, len(cityList)) for _, c := range cityList { @@ -69,28 +69,26 @@ 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. +// GetTerrain bootstraps a client with the map's dimensions and the ground it +// can currently see. Vision is ephemeral — it lasts only while a city, army or +// held structure is watching — so this is a snapshot, not a permanent reveal; +// the state stream sends a fresh set whenever the player's vision moves. 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. + seen, err := h.srv.watchers(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + 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...), + Visible: mapping.VisibleTerrainToProto(w, seen, constants.VisionRadius), }), nil } @@ -98,11 +96,11 @@ func (h *mapHandler) GetTile(ctx context.Context, req *connect.Request[servicev1 x := int(req.Msg.GetCoords().GetX()) y := int(req.Msg.GetCoords().GetY()) - owned, err := h.srv.ownedCities(ctx) + seen, err := h.srv.watchers(ctx) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } - if !domain.PointVisible(owned, x, y, constants.VisionRadius) { + if !domain.PointVisible(seen, x, y, constants.VisionRadius) { return nil, connect.NewError(connect.CodeNotFound, errors.New("tile not found")) } diff --git a/internal/rpc/rpc.go b/internal/rpc/rpc.go index d7f89a6..b954dbd 100644 --- a/internal/rpc/rpc.go +++ b/internal/rpc/rpc.go @@ -48,6 +48,31 @@ func (s *Server) ownedCities(ctx context.Context) ([]domain.City, error) { return s.store.GetCitiesByOwner(ctx, claims.UserID) } +// watchers builds the caller's vision set. Cities were once the only thing +// that could see, which meant an army could march the length of the map +// revealing nothing and a player's visible area never changed after founding. +func (s *Server) watchers(ctx context.Context) ([]domain.Watcher, error) { + claims, ok := auth.ClaimsFromContext(ctx) + if !ok { + return nil, errors.New("missing claims") + } + cities, err := s.store.GetCitiesByOwner(ctx, claims.UserID) + if err != nil { + return nil, err + } + all, err := s.store.GetAllArmies(ctx) + if err != nil { + return nil, err + } + armies := make([]domain.Army, 0, len(all)) + for _, a := range all { + if a.Owner == claims.UserID { + armies = append(armies, a) + } + } + return domain.WatchersFor(cities, armies), nil +} + func (s *Server) ownsCity(ctx context.Context, cityID string) (bool, error) { owned, err := s.ownedCities(ctx) if err != nil { diff --git a/internal/rpc/user.go b/internal/rpc/user.go index 2f0d221..7c7cdc6 100644 --- a/internal/rpc/user.go +++ b/internal/rpc/user.go @@ -9,6 +9,7 @@ import ( "golang.org/x/crypto/bcrypt" "cityio/internal/auth" + "cityio/internal/constants" entityv1 "cityio/internal/gen/cityio/entity/v1" servicev1 "cityio/internal/gen/cityio/service/v1" "cityio/internal/mapping" @@ -149,6 +150,20 @@ func (h *userHandler) StreamState(ctx context.Context, req *connect.Request[serv ch, unsubscribe := stream.Subscribe(claims.UserID) defer unsubscribe() + // Vision is ephemeral: it lasts only while a city, army or held structure is + // watching, so the visible set shrinks as readily as it grows. Rather than + // track reveals and concealments separately, resend the whole set whenever + // it changes and let the client replace what it has. lastVisible is what + // keeps that from being sent on every tick when nothing has moved. + var lastVisible *servicev1.VisibleTerrain + currentVisible := func() *servicev1.VisibleTerrain { + seen, err := h.srv.watchers(ctx) + if err != nil { + return nil + } + return mapping.VisibleTerrainToProto(h.srv.world, seen, constants.VisionRadius) + } + // Send initial snapshot: user, owned cities, and their buildings. if res, err := h.srv.cluster.Request("user", claims.UserID, messages.GetUserMessage{}); err == nil { if resp, ok := res.(*messages.GetUserResponseMessage); ok { @@ -188,7 +203,8 @@ func (h *userHandler) StreamState(ctx context.Context, req *connect.Request[serv } } - if err := out.Send(&servicev1.StreamStateResponse{Entities: bag}); err != nil { + lastVisible = currentVisible() + if err := out.Send(&servicev1.StreamStateResponse{Entities: bag, VisibleTerrain: lastVisible}); err != nil { return err } } @@ -226,7 +242,17 @@ func (h *userHandler) StreamState(ctx context.Context, req *connect.Request[serv if update.DeletedArmyID != nil { bag.DeletedArmyIds = append(bag.DeletedArmyIds, mapping.ToArmyId(*update.DeletedArmyID)) } - if err := out.Send(&servicev1.StreamStateResponse{Entities: bag}); err != nil { + res := &servicev1.StreamStateResponse{Entities: bag} + // Only cities and armies can move vision, and recomputing costs a + // query per subscriber — so skip it on the resource-only ticks that + // make up most of the stream. + if update.City != nil || update.Army != nil || update.DeletedArmyID != nil { + if visible := currentVisible(); !mapping.SameVisibleTerrain(visible, lastVisible) { + lastVisible = visible + res.VisibleTerrain = visible + } + } + if err := out.Send(res); err != nil { return err } } diff --git a/proto/cityio/service/v1/map.proto b/proto/cityio/service/v1/map.proto index f477505..d457b06 100644 --- a/proto/cityio/service/v1/map.proto +++ b/proto/cityio/service/v1/map.proto @@ -31,27 +31,36 @@ message GetTileResponse { message GetTerrainRequest {} -// GetTerrainResponse carries the whole map in one call. +// VisibleTerrain is the ground a player can see right now. // -// 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. +// Vision is ephemeral: it comes from cities, from armies while they stand +// somewhere, and from structures held against an enemy. Tiles therefore leave +// this set as often as they enter it, so it is always sent whole rather than as +// a reveal-only delta — replacing the set outright is the only way client and +// server cannot disagree about what is still lit. // -// Terrain is generated by the server from `seed` and does not change, so this -// response is stable for the lifetime of a world. +// Tiles are addressed by row-major index (y * width + x). Each plane holds one +// byte per entry of `indices`, in the same order. +message VisibleTerrain { + repeated int32 indices = 1; + bytes terrain = 2; // cityio.entity.v1.TerrainType + bytes relief = 3; // cityio.entity.v1.ReliefType + bytes feature = 4; // cityio.entity.v1.FeatureType + bytes special = 5; // cityio.entity.v1.SpecialType + // 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 = 6; +} + +// GetTerrainResponse bootstraps a client with the map's dimensions and whatever +// ground it can currently see. Everything else is unknown, and stays unknown +// until something of the player's is watching it. 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; + VisibleTerrain visible = 4; } // MapService serves world snapshots read from the persistence layer. diff --git a/proto/cityio/service/v1/user.proto b/proto/cityio/service/v1/user.proto index bedb30d..be609de 100644 --- a/proto/cityio/service/v1/user.proto +++ b/proto/cityio/service/v1/user.proto @@ -5,6 +5,7 @@ package cityio.service.v1; import "cityio/entity/v1/common.proto"; import "cityio/entity/v1/user.proto"; import "cityio/entity/v1/bag.proto"; +import "cityio/service/v1/map.proto"; message RegisterRequest { string email = 1; @@ -42,6 +43,10 @@ message StreamStateRequest {} // StreamStateResponse wraps an EntityBag pushed to a client whenever state changes. message StreamStateResponse { cityio.entity.v1.EntityBag entities = 1; + // The player's currently visible ground, sent whole whenever it changes and + // omitted when it has not. Absent on most ticks, since vision only moves when + // a city, army or watchtower does. + optional VisibleTerrain visible_terrain = 2; } // UserService manages player accounts and the per-user resource stream.