Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 24 additions & 15 deletions proto/cityio/service/v1/map.proto
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,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.
Expand Down
5 changes: 5 additions & 0 deletions proto/cityio/service/v1/user.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions src/lib/game/world/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,55 @@ export interface WorldMap {
special: Uint8Array;
/** 6-bit mask per tile: bit i means a river continues toward neighbor i. */
rivers: Uint8Array;
/**
* 1 where the player can see the ground right now. Vision is ephemeral — it
* lasts only while a city, army or held structure is watching — so tiles
* leave this set as readily as they enter it, and the planes above are only
* meaningful where it is set.
*/
visible: Uint8Array;
}

export function emptyWorld(w: number, h: number, seed: bigint): WorldMap {
const n = w * h;
return {
w,
h,
seed,
terrain: new Uint8Array(n),
relief: new Uint8Array(n),
feature: new Uint8Array(n),
special: new Uint8Array(n),
rivers: new Uint8Array(n),
visible: new Uint8Array(n)
};
}

/**
* Replace what the player can see with the set the server just sent.
*
* The whole set arrives every time rather than a reveal-only delta, because
* losing sight of ground is as common as gaining it. Clearing first is what
* makes a tile actually go dark when the army watching it walks away.
*/
export function applyVisibleTerrain(world: WorldMap, v: { indices: number[]; terrain: Uint8Array; relief: Uint8Array; feature: Uint8Array; special: Uint8Array; rivers: Uint8Array }): void {
world.terrain.fill(0);
world.relief.fill(0);
world.feature.fill(0);
world.special.fill(0);
world.rivers.fill(0);
world.visible.fill(0);

for (let k = 0; k < v.indices.length; k++) {
const i = v.indices[k];
if (i < 0 || i >= world.visible.length) continue;
world.terrain[i] = v.terrain[k];
world.relief[i] = v.relief[k];
world.feature[i] = v.feature[k];
world.special[i] = v.special[k];
world.rivers[i] = v.rivers[k];
world.visible[i] = 1;
}
}

export const isWater = (t: TerrainType) => t >= TerrainType.DEEP_OCEAN && t <= TerrainType.LAKE;
Expand Down
91 changes: 59 additions & 32 deletions src/lib/gen/cityio/service/v1/map_pb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file cityio/service/v1/map.proto.
*/
export const file_cityio_service_v1_map: GenFile = /*@__PURE__*/
fileDesc("ChtjaXR5aW8vc2VydmljZS92MS9tYXAucHJvdG8SEWNpdHlpby5zZXJ2aWNlLnYxIg8KDUdldE1hcFJlcXVlc3QinwEKDkdldE1hcFJlc3BvbnNlEioKCGNpdHlfaWRzGAEgAygLMhguY2l0eWlvLmVudGl0eS52MS5DaXR5SWQSMgoMYnVpbGRpbmdfaWRzGAIgAygLMhwuY2l0eWlvLmVudGl0eS52MS5CdWlsZGluZ0lkEi0KCGVudGl0aWVzGAMgASgLMhsuY2l0eWlvLmVudGl0eS52MS5FbnRpdHlCYWcioAEKBFRpbGUSCQoBeBgBIAEoBRIJCgF5GAIgASgFEi4KB2NpdHlfaWQYAyABKAsyGC5jaXR5aW8uZW50aXR5LnYxLkNpdHlJZEgAiAEBEjYKC2J1aWxkaW5nX2lkGAQgASgLMhwuY2l0eWlvLmVudGl0eS52MS5CdWlsZGluZ0lkSAGIAQFCCgoIX2NpdHlfaWRCDgoMX2J1aWxkaW5nX2lkIj8KDkdldFRpbGVSZXF1ZXN0Ei0KBmNvb3JkcxgBIAEoCzIdLmNpdHlpby5lbnRpdHkudjEuQ29vcmRpbmF0ZXMiOAoPR2V0VGlsZVJlc3BvbnNlEiUKBHRpbGUYASABKAsyFy5jaXR5aW8uc2VydmljZS52MS5UaWxlIhMKEUdldFRlcnJhaW5SZXF1ZXN0IpQBChJHZXRUZXJyYWluUmVzcG9uc2USDQoFd2lkdGgYASABKAUSDgoGaGVpZ2h0GAIgASgFEgwKBHNlZWQYAyABKAMSDwoHdGVycmFpbhgEIAEoDBIOCgZyZWxpZWYYBSABKAwSDwoHZmVhdHVyZRgGIAEoDBIPCgdzcGVjaWFsGAcgASgMEg4KBnJpdmVycxgIIAEoDDKIAgoKTWFwU2VydmljZRJNCgZHZXRNYXASIC5jaXR5aW8uc2VydmljZS52MS5HZXRNYXBSZXF1ZXN0GiEuY2l0eWlvLnNlcnZpY2UudjEuR2V0TWFwUmVzcG9uc2USUAoHR2V0VGlsZRIhLmNpdHlpby5zZXJ2aWNlLnYxLkdldFRpbGVSZXF1ZXN0GiIuY2l0eWlvLnNlcnZpY2UudjEuR2V0VGlsZVJlc3BvbnNlElkKCkdldFRlcnJhaW4SJC5jaXR5aW8uc2VydmljZS52MS5HZXRUZXJyYWluUmVxdWVzdBolLmNpdHlpby5zZXJ2aWNlLnYxLkdldFRlcnJhaW5SZXNwb25zZWIGcHJvdG8z", [file_cityio_entity_v1_common, file_cityio_entity_v1_bag]);
fileDesc("ChtjaXR5aW8vc2VydmljZS92MS9tYXAucHJvdG8SEWNpdHlpby5zZXJ2aWNlLnYxIg8KDUdldE1hcFJlcXVlc3QinwEKDkdldE1hcFJlc3BvbnNlEioKCGNpdHlfaWRzGAEgAygLMhguY2l0eWlvLmVudGl0eS52MS5DaXR5SWQSMgoMYnVpbGRpbmdfaWRzGAIgAygLMhwuY2l0eWlvLmVudGl0eS52MS5CdWlsZGluZ0lkEi0KCGVudGl0aWVzGAMgASgLMhsuY2l0eWlvLmVudGl0eS52MS5FbnRpdHlCYWcioAEKBFRpbGUSCQoBeBgBIAEoBRIJCgF5GAIgASgFEi4KB2NpdHlfaWQYAyABKAsyGC5jaXR5aW8uZW50aXR5LnYxLkNpdHlJZEgAiAEBEjYKC2J1aWxkaW5nX2lkGAQgASgLMhwuY2l0eWlvLmVudGl0eS52MS5CdWlsZGluZ0lkSAGIAQFCCgoIX2NpdHlfaWRCDgoMX2J1aWxkaW5nX2lkIj8KDkdldFRpbGVSZXF1ZXN0Ei0KBmNvb3JkcxgBIAEoCzIdLmNpdHlpby5lbnRpdHkudjEuQ29vcmRpbmF0ZXMiOAoPR2V0VGlsZVJlc3BvbnNlEiUKBHRpbGUYASABKAsyFy5jaXR5aW8uc2VydmljZS52MS5UaWxlIhMKEUdldFRlcnJhaW5SZXF1ZXN0InQKDlZpc2libGVUZXJyYWluEg8KB2luZGljZXMYASADKAUSDwoHdGVycmFpbhgCIAEoDBIOCgZyZWxpZWYYAyABKAwSDwoHZmVhdHVyZRgEIAEoDBIPCgdzcGVjaWFsGAUgASgMEg4KBnJpdmVycxgGIAEoDCJ1ChJHZXRUZXJyYWluUmVzcG9uc2USDQoFd2lkdGgYASABKAUSDgoGaGVpZ2h0GAIgASgFEgwKBHNlZWQYAyABKAMSMgoHdmlzaWJsZRgEIAEoCzIhLmNpdHlpby5zZXJ2aWNlLnYxLlZpc2libGVUZXJyYWluMogCCgpNYXBTZXJ2aWNlEk0KBkdldE1hcBIgLmNpdHlpby5zZXJ2aWNlLnYxLkdldE1hcFJlcXVlc3QaIS5jaXR5aW8uc2VydmljZS52MS5HZXRNYXBSZXNwb25zZRJQCgdHZXRUaWxlEiEuY2l0eWlvLnNlcnZpY2UudjEuR2V0VGlsZVJlcXVlc3QaIi5jaXR5aW8uc2VydmljZS52MS5HZXRUaWxlUmVzcG9uc2USWQoKR2V0VGVycmFpbhIkLmNpdHlpby5zZXJ2aWNlLnYxLkdldFRlcnJhaW5SZXF1ZXN0GiUuY2l0eWlvLnNlcnZpY2UudjEuR2V0VGVycmFpblJlc3BvbnNlYgZwcm90bzM", [file_cityio_entity_v1_common, file_cityio_entity_v1_bag]);

/**
* @generated from message cityio.service.v1.GetMapRequest
Expand Down Expand Up @@ -138,59 +138,50 @@ export const GetTerrainRequestSchema: GenMessage<GetTerrainRequest> = /*@__PURE_
messageDesc(file_cityio_service_v1_map, 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.
* Tiles are addressed by row-major index (y * width + x). Each plane holds one
* byte per entry of `indices`, in the same order.
*
* @generated from message cityio.service.v1.GetTerrainResponse
* @generated from message cityio.service.v1.VisibleTerrain
*/
export type GetTerrainResponse = Message<"cityio.service.v1.GetTerrainResponse"> & {
/**
* @generated from field: int32 width = 1;
*/
width: number;

/**
* @generated from field: int32 height = 2;
*/
height: number;

export type VisibleTerrain = Message<"cityio.service.v1.VisibleTerrain"> & {
/**
* @generated from field: int64 seed = 3;
* @generated from field: repeated int32 indices = 1;
*/
seed: bigint;
indices: number[];

/**
* cityio.entity.v1.TerrainType per tile
* cityio.entity.v1.TerrainType
*
* @generated from field: bytes terrain = 4;
* @generated from field: bytes terrain = 2;
*/
terrain: Uint8Array;

/**
* cityio.entity.v1.ReliefType per tile
* cityio.entity.v1.ReliefType
*
* @generated from field: bytes relief = 5;
* @generated from field: bytes relief = 3;
*/
relief: Uint8Array;

/**
* cityio.entity.v1.FeatureType per tile
* cityio.entity.v1.FeatureType
*
* @generated from field: bytes feature = 6;
* @generated from field: bytes feature = 4;
*/
feature: Uint8Array;

/**
* cityio.entity.v1.SpecialType per tile
* cityio.entity.v1.SpecialType
*
* @generated from field: bytes special = 7;
* @generated from field: bytes special = 5;
*/
special: Uint8Array;

Expand All @@ -199,17 +190,53 @@ export type GetTerrainResponse = Message<"cityio.service.v1.GetTerrainResponse">
* 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.
*
* @generated from field: bytes rivers = 8;
* @generated from field: bytes rivers = 6;
*/
rivers: Uint8Array;
};

/**
* Describes the message cityio.service.v1.VisibleTerrain.
* Use `create(VisibleTerrainSchema)` to create a new message.
*/
export const VisibleTerrainSchema: GenMessage<VisibleTerrain> = /*@__PURE__*/
messageDesc(file_cityio_service_v1_map, 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.
*
* @generated from message cityio.service.v1.GetTerrainResponse
*/
export type GetTerrainResponse = Message<"cityio.service.v1.GetTerrainResponse"> & {
/**
* @generated from field: int32 width = 1;
*/
width: number;

/**
* @generated from field: int32 height = 2;
*/
height: number;

/**
* @generated from field: int64 seed = 3;
*/
seed: bigint;

/**
* @generated from field: cityio.service.v1.VisibleTerrain visible = 4;
*/
visible?: VisibleTerrain | undefined;
};

/**
* Describes the message cityio.service.v1.GetTerrainResponse.
* Use `create(GetTerrainResponseSchema)` to create a new message.
*/
export const GetTerrainResponseSchema: GenMessage<GetTerrainResponse> = /*@__PURE__*/
messageDesc(file_cityio_service_v1_map, 6);
messageDesc(file_cityio_service_v1_map, 7);

/**
* MapService serves world snapshots read from the persistence layer.
Expand Down
13 changes: 12 additions & 1 deletion src/lib/gen/cityio/service/v1/user_pb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import type { User } from "../../entity/v1/user_pb";
import { file_cityio_entity_v1_user } from "../../entity/v1/user_pb";
import type { EntityBag } from "../../entity/v1/bag_pb";
import { file_cityio_entity_v1_bag } from "../../entity/v1/bag_pb";
import type { VisibleTerrain } from "./map_pb";
import { file_cityio_service_v1_map } from "./map_pb";
import type { Message } from "@bufbuild/protobuf";

/**
* Describes the file cityio/service/v1/user.proto.
*/
export const file_cityio_service_v1_user: GenFile = /*@__PURE__*/
fileDesc("ChxjaXR5aW8vc2VydmljZS92MS91c2VyLnByb3RvEhFjaXR5aW8uc2VydmljZS52MSJECg9SZWdpc3RlclJlcXVlc3QSDQoFZW1haWwYASABKAkSEAoIdXNlcm5hbWUYAiABKAkSEAoIcGFzc3dvcmQYAyABKAkiTAoQUmVnaXN0ZXJSZXNwb25zZRIpCgd1c2VyX2lkGAEgASgLMhguY2l0eWlvLmVudGl0eS52MS5Vc2VySWQSDQoFdG9rZW4YAiABKAkiNAoMTG9naW5SZXF1ZXN0EhIKCmlkZW50aWZpZXIYASABKAkSEAoIcGFzc3dvcmQYAiABKAkiRAoNTG9naW5SZXNwb25zZRINCgV0b2tlbhgBIAEoCRIkCgR1c2VyGAIgASgLMhYuY2l0eWlvLmVudGl0eS52MS5Vc2VyIjsKDkdldFVzZXJSZXF1ZXN0EikKB3VzZXJfaWQYASABKAsyGC5jaXR5aW8uZW50aXR5LnYxLlVzZXJJZCI3Cg9HZXRVc2VyUmVzcG9uc2USJAoEdXNlchgBIAEoCzIWLmNpdHlpby5lbnRpdHkudjEuVXNlciI+ChFEZWxldGVVc2VyUmVxdWVzdBIpCgd1c2VyX2lkGAEgASgLMhguY2l0eWlvLmVudGl0eS52MS5Vc2VySWQiFAoSRGVsZXRlVXNlclJlc3BvbnNlIhQKElN0cmVhbVN0YXRlUmVxdWVzdCJEChNTdHJlYW1TdGF0ZVJlc3BvbnNlEi0KCGVudGl0aWVzGAEgASgLMhsuY2l0eWlvLmVudGl0eS52MS5FbnRpdHlCYWcyuwMKC1VzZXJTZXJ2aWNlElMKCFJlZ2lzdGVyEiIuY2l0eWlvLnNlcnZpY2UudjEuUmVnaXN0ZXJSZXF1ZXN0GiMuY2l0eWlvLnNlcnZpY2UudjEuUmVnaXN0ZXJSZXNwb25zZRJKCgVMb2dpbhIfLmNpdHlpby5zZXJ2aWNlLnYxLkxvZ2luUmVxdWVzdBogLmNpdHlpby5zZXJ2aWNlLnYxLkxvZ2luUmVzcG9uc2USUAoHR2V0VXNlchIhLmNpdHlpby5zZXJ2aWNlLnYxLkdldFVzZXJSZXF1ZXN0GiIuY2l0eWlvLnNlcnZpY2UudjEuR2V0VXNlclJlc3BvbnNlElkKCkRlbGV0ZVVzZXISJC5jaXR5aW8uc2VydmljZS52MS5EZWxldGVVc2VyUmVxdWVzdBolLmNpdHlpby5zZXJ2aWNlLnYxLkRlbGV0ZVVzZXJSZXNwb25zZRJeCgtTdHJlYW1TdGF0ZRIlLmNpdHlpby5zZXJ2aWNlLnYxLlN0cmVhbVN0YXRlUmVxdWVzdBomLmNpdHlpby5zZXJ2aWNlLnYxLlN0cmVhbVN0YXRlUmVzcG9uc2UwAWIGcHJvdG8z", [file_cityio_entity_v1_common, file_cityio_entity_v1_user, file_cityio_entity_v1_bag]);
fileDesc("ChxjaXR5aW8vc2VydmljZS92MS91c2VyLnByb3RvEhFjaXR5aW8uc2VydmljZS52MSJECg9SZWdpc3RlclJlcXVlc3QSDQoFZW1haWwYASABKAkSEAoIdXNlcm5hbWUYAiABKAkSEAoIcGFzc3dvcmQYAyABKAkiTAoQUmVnaXN0ZXJSZXNwb25zZRIpCgd1c2VyX2lkGAEgASgLMhguY2l0eWlvLmVudGl0eS52MS5Vc2VySWQSDQoFdG9rZW4YAiABKAkiNAoMTG9naW5SZXF1ZXN0EhIKCmlkZW50aWZpZXIYASABKAkSEAoIcGFzc3dvcmQYAiABKAkiRAoNTG9naW5SZXNwb25zZRINCgV0b2tlbhgBIAEoCRIkCgR1c2VyGAIgASgLMhYuY2l0eWlvLmVudGl0eS52MS5Vc2VyIjsKDkdldFVzZXJSZXF1ZXN0EikKB3VzZXJfaWQYASABKAsyGC5jaXR5aW8uZW50aXR5LnYxLlVzZXJJZCI3Cg9HZXRVc2VyUmVzcG9uc2USJAoEdXNlchgBIAEoCzIWLmNpdHlpby5lbnRpdHkudjEuVXNlciI+ChFEZWxldGVVc2VyUmVxdWVzdBIpCgd1c2VyX2lkGAEgASgLMhguY2l0eWlvLmVudGl0eS52MS5Vc2VySWQiFAoSRGVsZXRlVXNlclJlc3BvbnNlIhQKElN0cmVhbVN0YXRlUmVxdWVzdCKZAQoTU3RyZWFtU3RhdGVSZXNwb25zZRItCghlbnRpdGllcxgBIAEoCzIbLmNpdHlpby5lbnRpdHkudjEuRW50aXR5QmFnEj8KD3Zpc2libGVfdGVycmFpbhgCIAEoCzIhLmNpdHlpby5zZXJ2aWNlLnYxLlZpc2libGVUZXJyYWluSACIAQFCEgoQX3Zpc2libGVfdGVycmFpbjK7AwoLVXNlclNlcnZpY2USUwoIUmVnaXN0ZXISIi5jaXR5aW8uc2VydmljZS52MS5SZWdpc3RlclJlcXVlc3QaIy5jaXR5aW8uc2VydmljZS52MS5SZWdpc3RlclJlc3BvbnNlEkoKBUxvZ2luEh8uY2l0eWlvLnNlcnZpY2UudjEuTG9naW5SZXF1ZXN0GiAuY2l0eWlvLnNlcnZpY2UudjEuTG9naW5SZXNwb25zZRJQCgdHZXRVc2VyEiEuY2l0eWlvLnNlcnZpY2UudjEuR2V0VXNlclJlcXVlc3QaIi5jaXR5aW8uc2VydmljZS52MS5HZXRVc2VyUmVzcG9uc2USWQoKRGVsZXRlVXNlchIkLmNpdHlpby5zZXJ2aWNlLnYxLkRlbGV0ZVVzZXJSZXF1ZXN0GiUuY2l0eWlvLnNlcnZpY2UudjEuRGVsZXRlVXNlclJlc3BvbnNlEl4KC1N0cmVhbVN0YXRlEiUuY2l0eWlvLnNlcnZpY2UudjEuU3RyZWFtU3RhdGVSZXF1ZXN0GiYuY2l0eWlvLnNlcnZpY2UudjEuU3RyZWFtU3RhdGVSZXNwb25zZTABYgZwcm90bzM", [file_cityio_entity_v1_common, file_cityio_entity_v1_user, file_cityio_entity_v1_bag, file_cityio_service_v1_map]);

/**
* @generated from message cityio.service.v1.RegisterRequest
Expand Down Expand Up @@ -198,6 +200,15 @@ export type StreamStateResponse = Message<"cityio.service.v1.StreamStateResponse
* @generated from field: cityio.entity.v1.EntityBag entities = 1;
*/
entities?: EntityBag | undefined;

/**
* 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.
*
* @generated from field: optional cityio.service.v1.VisibleTerrain visible_terrain = 2;
*/
visibleTerrain?: VisibleTerrain | undefined;
};

/**
Expand Down
19 changes: 16 additions & 3 deletions src/routes/game/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { mapClient, userClient, configClient } from '$lib/api/client';
import { token, buildings as buildingsStore, capital, cities as citiesStore, food, foodIncomePerHour, foodUpkeepPerHour, gameConfig, gold, mapCenter, userId, world } from '$lib/stores';
import { applyVisibleTerrain, emptyWorld } from '$lib/game/world';
import { ratePerHour } from '$lib/game/rates';
import { isTokenValid, handleUnauthenticated } from '$lib/session';
import { Code, ConnectError } from '@connectrpc/connect';
Expand Down Expand Up @@ -43,12 +44,15 @@
}
};

// Terrain is generated and owned by the server. It never changes, so this is
// fetched once and cached for the session.
// Terrain is generated and owned by the server, and only the ground the
// player can currently see is sent. Vision is ephemeral, so the stream keeps
// this up to date as cities, armies and held structures move.
const loadTerrain = async (): Promise<boolean> => {
try {
const t = await mapClient.getTerrain({});
world.set({ w: t.width, h: t.height, seed: t.seed, terrain: t.terrain, relief: t.relief, feature: t.feature, special: t.special, rivers: t.rivers });
const w = emptyWorld(t.width, t.height, t.seed);
if (t.visible) applyVisibleTerrain(w, t.visible);
world.set(w);
return true;
} catch (err) {
if (err instanceof ConnectError && err.code === Code.Unauthenticated) return false;
Expand Down Expand Up @@ -88,6 +92,15 @@
while (!signal.aborted) {
try {
for await (const state of userClient.streamState({}, { signal })) {
// Vision moved: replace what the player can see. Absent on most
// ticks, since only a city, army or watchtower moving changes it.
if (state.visibleTerrain) {
world.update((w) => {
if (w) applyVisibleTerrain(w, state.visibleTerrain!);
return w;
});
}

const bag = state.entities;
if (!bag) continue;

Expand Down
Loading
Loading