From 151535106225bdaecb3b7ef0680ac118c48c6e74 Mon Sep 17 00:00:00 2001 From: Jason Jiang Date: Mon, 3 Aug 2026 00:08:56 -0400 Subject: [PATCH] feat: gate terrain behind live vision and let armies see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terrain was the only thing in the game not filtered by vision. Cities, buildings, armies and tiles all went through the fog filters; the map did not, so any authenticated client could read the whole world — where the fertile land is, where the chokepoints are, where the gold sits. That is exactly what an opponent should have to scout for. Vision is now ephemeral and contested rather than a property of ownership. It comes from watchers: a city watches its footprint, an army watches the tile it stands on. Ground is lit only while something is actually looking at it and goes dark again when that thing leaves, so holding sight of the map becomes a thing to maintain rather than something granted permanently by founding a city. Replace the cities-only vision helpers with a Watcher box so any entity can contribute. Armies previously could not see at all — they were merely filtered by city vision — which meant marching one across the map revealed nothing and a player's view never changed after founding. GetTerrain now returns only currently visible ground, and StreamState carries a fresh VisibleTerrain whenever that set moves. It is always sent whole rather than as a reveal-only delta: tiles leave the set as readily as they enter it, and replacing it outright is the only way client and server cannot drift about what is still lit. Recomputation is skipped on ticks that cannot have moved vision, since it costs a query per subscriber. This is groundwork for buildable watchtowers and fortresses on captured neutral tiles: vision you build, and can lose. Co-Authored-By: Claude Opus 5 (1M context) --- internal/domain/visibility.go | 114 ++++++++---- internal/gen/cityio/service/v1/map.pb.go | 213 ++++++++++++++-------- internal/gen/cityio/service/v1/user.pb.go | 61 ++++--- internal/mapping/terrain.go | 68 +++++++ internal/rpc/army.go | 4 +- internal/rpc/building.go | 8 +- internal/rpc/city.go | 4 +- internal/rpc/map.go | 34 ++-- internal/rpc/rpc.go | 25 +++ internal/rpc/user.go | 30 ++- proto/cityio/service/v1/map.proto | 39 ++-- proto/cityio/service/v1/user.proto | 5 + 12 files changed, 432 insertions(+), 173 deletions(-) create mode 100644 internal/mapping/terrain.go 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.