diff --git a/client/js/map_view/input.js b/client/js/map_view/input.js index 13068ff..772750a 100644 --- a/client/js/map_view/input.js +++ b/client/js/map_view/input.js @@ -1,9 +1,9 @@ // Camera, zoom/LOD switching, pointer input, selection and screen-to-tile // hit-testing. -import { mapToLocal, key, COL_STEP, HEX_W, HEX_H } from "../../../shared/hex.js"; +import { mapToLocal, key, parity, COL_STEP, HEX_W, HEX_H } from "../../../shared/hex.js"; import { DRAG_THRESHOLD, ZOOM_MIN, ZOOM_MAX } from "./constants.js"; -import { clamp, parity } from "./utils.js"; +import { clamp } from "./utils.js"; export const inputMethods = { // ------------------------------------------------------------- camera -- diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js index 70f4b68..57b690c 100644 --- a/client/js/map_view/motion.js +++ b/client/js/map_view/motion.js @@ -3,6 +3,7 @@ import { mapToLocal, key } from "../../../shared/hex.js"; import { HexPathfinder } from "../../../shared/hex_pathfinder.js"; +import { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js"; import { RESYNC_HOURS, PREDICTION_GRACE_HOURS } from "./constants.js"; import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js"; @@ -247,7 +248,8 @@ export const motionMethods = { const costCache = new Map(); const heuristicCache = new Map(); const bestSpeed = Math.max(speed || proto.speed, 0.0001); - const fastestHours = this.terrainStats.minMovementCostMultiplier() / (bestSpeed * 1.5); + const fastestHours = + this.terrainStats.minMovementCostMultiplier() / (bestSpeed * CONTROLLED_SPEED_MULTIPLIER); const iterator = new HexPathfinder().findPathIter( start, goal, @@ -398,7 +400,7 @@ export const motionMethods = { _stepHours(proto, civ, coords, speed = null) { const k = key(coords.x, coords.y); let effective = Math.max(speed || proto.speed, 0.0001); - if (this.territory.get(k) === civ) effective *= 1.5; + if (this.territory.get(k) === civ) effective *= CONTROLLED_SPEED_MULTIPLIER; return this.terrainStats.movementCostMultiplier(coords) / effective; }, }; diff --git a/client/js/map_view/utils.js b/client/js/map_view/utils.js index 10d5022..128f11c 100644 --- a/client/js/map_view/utils.js +++ b/client/js/map_view/utils.js @@ -5,10 +5,6 @@ import { HEX_W, HEX_H } from "../../../shared/hex.js"; import { OVERVIEW_COLOURS, OVERVIEW_SEA } from "./constants.js"; -export function parity(x) { - return x & 1; -} - // Order-independent FNV-style hash of an [x, y, ...] entry array, used to skip // rebuilding a layer when the snapshot did not actually change it. export function hashEntries(entries) { diff --git a/server/game_server.js b/server/game_server.js index 17dbf49..afc36b5 100644 --- a/server/game_server.js +++ b/server/game_server.js @@ -120,14 +120,8 @@ export class GameServer extends EventEmitter { } _handleMove(civ, order) { - const ids = Array.isArray(order.units) ? order.units : [order.unit]; - const units = []; - for (const id of ids) { - const unit = this.state.findUnit(Number(id)); - if (!unit || unit.civ !== civ) return false; - units.push(unit.id); - } - if (units.length === 0) return false; + const units = this._ownUnitIds(civ, order, true); + if (!units) return false; return this.state.requestGroupMove(units, coordsOf(order.coords)); } @@ -146,26 +140,26 @@ export class GameServer extends EventEmitter { return this.state.requestScheduledMove(units, goals, !!order.append); } - _ownUnitIds(civ, order) { + // The caller's own unit ids. With `strict`, a single id that is unknown or + // owned by another civilisation rejects the whole order (null); otherwise the + // foreign ids are simply dropped. + _ownUnitIds(civ, order, strict = false) { const ids = Array.isArray(order.units) ? order.units : [order.unit]; const units = []; for (const id of ids) { const unit = this.state.findUnit(Number(id)); - if (!unit || unit.civ !== civ) continue; + if (!unit || unit.civ !== civ) { + if (strict) return null; + continue; + } units.push(unit.id); } return units; } _handlePillage(civ, order) { - const ids = Array.isArray(order.units) ? order.units : [order.unit]; - const units = []; - for (const id of ids) { - const unit = this.state.findUnit(Number(id)); - if (!unit || unit.civ !== civ) return false; - units.push(unit.id); - } - if (units.length === 0) return false; + const units = this._ownUnitIds(civ, order, true); + if (!units) return false; return this.state.requestPillage(units); } diff --git a/shared/data/relations.js b/shared/data/relations.js index 987499b..0d4619a 100644 --- a/shared/data/relations.js +++ b/shared/data/relations.js @@ -4,7 +4,6 @@ export const RELATION_PEACE = "peace"; export const RELATION_WAR = "war"; -export const RELATIONS = [RELATION_PEACE, RELATION_WAR]; // Kinds of news item shown in the feed. Newsworthy events are recorded in // GameState with their in-game hour so every client can render them. diff --git a/shared/game_state.js b/shared/game_state.js index 677048f..edb4869 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -73,7 +73,22 @@ export class GameState { this.tiles = {}; this.terrainStats = null; this.landCells = []; + this.totalHours = 0; + this._configured = false; + this._generator = null; + this._pathfinder = new HexPathfinder(); + this._minMovementCost = 1.0; + this._maxStepLength = 1.0; + this._listeners = []; + + this._resetCollections(); + } + + // Recreates every per-game collection, index and counter. Shared by the + // constructor and `configure`, so a fresh instance and a restarted game + // always start from exactly the same shape. + _resetCollections() { this.cities = []; this.units = []; this.territory = new Map(); @@ -100,13 +115,6 @@ export class GameState { this.conflicts = new Map(); this.news = []; - this.totalHours = 0; - - this._configured = false; - this._generator = null; - this._pathfinder = new HexPathfinder(); - this._minMovementCost = 1.0; - this._maxStepLength = 1.0; this._nextUnitId = 1; this._nextCityId = 1; this._nextNewsId = 1; @@ -126,8 +134,6 @@ export class GameState { this._serializedExplored = new Map(); this._neighbourCache = new Map(); this._visibilityDirty = true; - - this._listeners = []; } onChanged(fn) { @@ -157,40 +163,7 @@ export class GameState { this._minMovementCost = this.terrainStats.minMovementCostMultiplier(); this._maxStepLength = this._computeMaxStepLength(); - this.cities = []; - this.units = []; - this.territory = new Map(); - this.tilePopulation = new Map(); - this.tileGdpPenalty = new Map(); - this.tileBattleGdpDeficit = new Map(); - this.tileBattleRecoveryStart = new Map(); - this.budgets = new Map(); - this.training = new Map(); - this.research = new Map(); - this.culture = new Map(); - this.government = new Map(); - this.researched = new Map(); - this.wars = new Set(); - this.conflicts = new Map(); - this.news = []; - this._nextNewsId = 1; - this._nextUnitId = 1; - this._nextCityId = 1; - this._unitById = new Map(); - this._cityById = new Map(); - this._cityNamesUsed = new Map(); - this._usedCityNames = new Set(); - this._cityByCoords = new Map(); - this._cityAdjacent = new Map(); - this._territoryByCiv = new Map(); - this._gdpPerCapitaCache = new Map(); - this._populationBalance = new Map(); - this._perCapitaBalance = new Map(); - this._serializedTerritory = []; - this._serializedExplored = new Map(); - this._neighbourCache = new Map(); - this.explored = new Map(); - this.visible = new Map(); + this._resetCollections(); for (let i = 0; i < this.civilisations.length; i++) { this.research.set(i, 0); this.culture.set(i, 0); diff --git a/shared/game_state/constants.js b/shared/game_state/constants.js index e24720b..445efb2 100644 --- a/shared/game_state/constants.js +++ b/shared/game_state/constants.js @@ -7,9 +7,9 @@ export const MAX_NEWS = 100; export const UNIT_VISION = 2; export const TERRITORY_VISION = 1; -export const HOURS_PER_DAY = 24; -export const DAYS_PER_YEAR = 365; -export const HOURS_PER_YEAR = HOURS_PER_DAY * DAYS_PER_YEAR; +// The calendar lives in one place: re-exported so model code can keep +// importing every simulation constant from this module. +export { HOURS_PER_DAY, DAYS_PER_YEAR, HOURS_PER_YEAR } from "../game_clock.js"; export const CONTROLLED_SPEED_MULTIPLIER = 1.5; export const DEFAULT_UNIT_SPEED = 0.5; export const MIN_STEP_COST = 0.0001; diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index 416f9f6..3db7352 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -9,7 +9,12 @@ import { EFFECT_CULTURE, } from "../data/effects.js"; import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.js"; -import { buildingUpkeep, prerequisitesMet } from "../rules.js"; +import { + buildingUpkeep, + governmentEffects, + prerequisitesMet, + technologyEffects, +} from "../rules.js"; import { key } from "../hex.js"; import { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js"; import { effectAt, mergeModifiers } from "./helpers.js"; @@ -232,13 +237,8 @@ export const economyMethods = { }, getCivGovernmentModifiers(civ) { - const totals = {}; const resource = this.governments[this.getGovernment(civ)]; - if (!resource) return totals; - for (const e of resource.effects) { - totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, 1); - } - return totals; + return resource ? governmentEffects(resource) : {}; }, getCivModifiers(civ) { @@ -293,9 +293,7 @@ export const economyMethods = { const totals = {}; const set = this.researched.get(civ) || new Set(); for (const index of set) { - for (const e of this.technologies[index].effects) { - totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, 1); - } + mergeModifiers(totals, technologyEffects(this.technologies[index])); } return totals; }, diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index eccccf7..93a167e 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -1,7 +1,7 @@ // Serialisation: turning the live model into the snapshots the server ships to // each viewer, with fog-of-war applied per civilisation. -import { key, parseKey } from "../hex.js"; +import { parseKey } from "../hex.js"; import { serializeSet } from "./helpers.js"; export const serializationMethods = { @@ -23,10 +23,10 @@ export const serializationMethods = { territory: this._serializedTerritory, population: this._serializePopulation(), civStats: this._serializeCivStats(), - budgets: this._serializeCivFloats(this.budgets), - research: this._serializeCivFloats(this.research), - culture: this._serializeCivFloats(this.culture), - government: this._serializeCivInts(this.government), + budgets: this._serializeCivValues(this.budgets), + research: this._serializeCivValues(this.research), + culture: this._serializeCivValues(this.culture), + government: this._serializeCivValues(this.government), researched: this._serializeResearched(), diplomacy: this._serializeDiplomacy(), conflicts: this._serializeConflicts(), @@ -58,15 +58,11 @@ export const serializationMethods = { breakdown: null, }; } - const aggregates = this._playerAggregates(viewerCiv); - const population = Math.round(aggregates.population); - const gdp = aggregates.gdp; + const headline = this._civHeadline(viewerCiv); return { - population, - gdp, - gdpPerCapita: population > 0 ? gdp / population : 0, + ...headline, budget: this.getBudget(viewerCiv), - upkeep: this.getPlayerUpkeep(viewerCiv, gdp), + upkeep: this.getPlayerUpkeep(viewerCiv, headline.gdp), research: this.getResearch(viewerCiv), culture: this.getCulture(viewerCiv), government: this.getGovernment(viewerCiv), @@ -132,27 +128,19 @@ export const serializationMethods = { // One entry per civilisation with its headline economy figures, so the // diplomacy panel can show every nation's standing without a second lookup. _serializeCivStats() { - return this.civilisations.map((civ, index) => { - const aggregates = this._playerAggregates(index); - const population = Math.round(aggregates.population); - const gdp = aggregates.gdp; - return { - population, - gdp, - gdpPerCapita: population > 0 ? gdp / population : 0, - }; - }); + return this.civilisations.map((_, index) => this._civHeadline(index)); }, - _serializeCivFloats(values) { - const result = []; - for (let i = 0; i < this.civilisations.length; i++) { - result.push(values.has(i) ? values.get(i) : 0); - } - return result; + // Population, GDP and GDP per capita for one civilisation: the headline + // figures the diplomacy panel and the viewer's own stats both show. + _civHeadline(civ) { + const aggregates = this._playerAggregates(civ); + const population = Math.round(aggregates.population); + const gdp = aggregates.gdp; + return { population, gdp, gdpPerCapita: population > 0 ? gdp / population : 0 }; }, - _serializeCivInts(values) { + _serializeCivValues(values) { const result = []; for (let i = 0; i < this.civilisations.length; i++) { result.push(values.has(i) ? values.get(i) : 0); diff --git a/shared/game_state/world.js b/shared/game_state/world.js index e518d48..ef1bc59 100644 --- a/shared/game_state/world.js +++ b/shared/game_state/world.js @@ -35,15 +35,17 @@ export const worldMethods = { const civ = this.civilisations[civIndex]; const coords = site.coords; const isCapital = site.isCapital; - const description = this._cityDescriptionFor(civ, isCapital, coords); + const name = this._nextCityName(civ, civIndex); + const population = 500000 + + (Math.abs((coords.x * 92837111) ^ (coords.y * 689287499)) % 3500000); const city = { id: this._nextCityId, civ: civIndex, coords, - name: description.name, + name, isCapital, - population: description.population, - improvements: description.improvements.slice(), + population, + improvements: [], buildings: {}, statuses: [], }; @@ -52,10 +54,6 @@ export const worldMethods = { this._nextCityId += 1; }, - _cityDescriptionFor(civ, _isCapital, center) { - return this._generatedCityDescription(civ, center); - }, - _rebuildCityIndex() { this._cityByCoords = new Map(); this._cityAdjacent = new Map(); @@ -70,14 +68,6 @@ export const worldMethods = { } }, - _generatedCityDescription(civ, center) { - const index = this.civilisations.indexOf(civ); - const name = this._nextCityName(civ, index); - const population = 500000 + - (Math.abs((center.x * 92837111) ^ (center.y * 689287499)) % 3500000); - return { name, population, improvements: [] }; - }, - // Picks the civilisation's next city name, skipping any a city elsewhere has // already taken, and falls back to a numbered name so the whole world never // has two cities called the same thing. diff --git a/shared/rules.js b/shared/rules.js index 9fb948f..c0a8750 100644 --- a/shared/rules.js +++ b/shared/rules.js @@ -30,7 +30,9 @@ export function governmentChangeCost(proto, gdp) { return proto.baseCost + proto.costGdpFraction * Math.max(gdp, 0); } -export function governmentEffects(proto) { +// Combined effect totals for a catalogue entry whose effects do not scale +// (governments and technologies are always applied at level 1). +function effectsTotal(proto) { const totals = {}; for (const e of proto.effects) { totals[e.stat] = (totals[e.stat] || 0) + effectValue(e, 1); @@ -38,12 +40,12 @@ export function governmentEffects(proto) { return totals; } +export function governmentEffects(proto) { + return effectsTotal(proto); +} + export function technologyEffects(proto) { - const totals = {}; - for (const e of proto.effects) { - totals[e.stat] = (totals[e.stat] || 0) + effectValue(e, 1); - } - return totals; + return effectsTotal(proto); } // True when every prerequisite technology is in the researched set. The set diff --git a/shared/terrain_stats.js b/shared/terrain_stats.js index 61ec139..660499a 100644 --- a/shared/terrain_stats.js +++ b/shared/terrain_stats.js @@ -2,6 +2,8 @@ // movement multiplier of 0 (an unauthored layer, or a non-land tile) falls back // to a normal cost so a unit is never frozen. +import { key, parseKey } from "./hex.js"; + export const DEFAULT_MOVEMENT_COST_MULTIPLIER = 1.0; export class TerrainStats { @@ -11,7 +13,7 @@ export class TerrainStats { } _value(coords, field) { - const tile = this._tiles[coords.x + "," + coords.y]; + const tile = this._tiles[key(coords.x, coords.y)]; return tile ? (tile[field] || 0) : 0; } @@ -35,10 +37,7 @@ export class TerrainStats { if (this._minMovementCost !== undefined) return this._minMovementCost; let cheapest = Infinity; for (const k in this._tiles) { - const value = this.movementCostMultiplier({ - x: Number(k.slice(0, k.indexOf(","))), - y: Number(k.slice(k.indexOf(",") + 1)), - }); + const value = this.movementCostMultiplier(parseKey(k)); if (value < cheapest) cheapest = value; } this._minMovementCost = cheapest < Infinity ? cheapest : DEFAULT_MOVEMENT_COST_MULTIPLIER; diff --git a/tests/framework/helpers.js b/tests/framework/helpers.js index 4775280..c4f7b31 100644 --- a/tests/framework/helpers.js +++ b/tests/framework/helpers.js @@ -2,6 +2,7 @@ // throughout so tests are fast and their results stable. import { GameState } from "../../shared/game_state.js"; +import { parseKey } from "../../shared/hex.js"; import { MAP_CONFIG, CIVILISATIONS } from "../../shared/data.js"; import { startServer } from "../../server/server.js"; @@ -18,7 +19,6 @@ export async function withServer(fn) { } export const SEED = 12345; -export const LAND = "Land"; export const SEA = "Sea"; export function smallConfig(overrides = {}) { @@ -72,10 +72,7 @@ export function adjacentLand(state, unit) { export function seaTile(state) { for (const k in state.tiles) { - if (state.tiles[k].terrainClass === SEA) { - const i = k.indexOf(","); - return { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) }; - } + if (state.tiles[k].terrainClass === SEA) return parseKey(k); } return null; } diff --git a/tests/game_server_test.js b/tests/game_server_test.js index ee8f208..195b2a6 100644 --- a/tests/game_server_test.js +++ b/tests/game_server_test.js @@ -1,7 +1,7 @@ import { TestCase } from "./framework/test_case.js"; import { GameServer } from "../server/game_server.js"; import { CIVILISATIONS } from "../shared/data.js"; -import { smallConfig, adjacentLand, SEED } from "./framework/helpers.js"; +import { adjacentLand, SEED } from "./framework/helpers.js"; function fakeNetwork() { return { diff --git a/tests/game_state_test.js b/tests/game_state_test.js index e458099..425e1ba 100644 --- a/tests/game_state_test.js +++ b/tests/game_state_test.js @@ -7,7 +7,6 @@ import { cityOf, unitsOf, snapshotUnit, - SEED, } from "./framework/helpers.js"; export class GameStateTest extends TestCase {