From 70d62fc0fea5ed025242053b44c7aadc8484943d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:58:31 +0000 Subject: [PATCH 1/4] Blockilization: Civ V's combat bonuses and unit numbers; formations as Civ VI's Spearmen and pikemen get +50% against mounted units (Civ V's), not +100%; Lancers and Winged Hussars Formation I's +33%. Siege gets +200% against cities, no defence from hills or woods, and a tile of sight. Armour has no penalty at cities and moves on after attacking. The Anti-Tank Gun and Helicopter Gunship get +100% against armour, and the Bazooka fires at range 1. Base numbers to Civ V's: Work Boat, Trireme, Anti-Aircraft Gun and Giant Death Robot costs; Ironclad, Mobile SAM and Rocket Artillery moves; Submarine and Nuclear Submarine range; Scout, Jet Fighter and XCOM strength; the Nuclear Missile takes two Uranium; the paradrop is 9 tiles again. Unique units to Civ V's numbers, with the promotions Civ V gives them in place of extra strength (inborn, not counted against experience). A corps's or army's bonus multiplies the other modifiers rather than being added to them, so it isn't diluted on a hill or fortified; their gold upkeep is Civ VI's 1.5 and 2 units'. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJp3oDGF1p3b1w297oSHYf --- src/games/blockilization/client/concepts.ts | 11 +-- src/games/blockilization/rules/armies.ts | 14 ++- src/games/blockilization/rules/combat.ts | 31 ++++--- src/games/blockilization/rules/economy.ts | 6 +- src/games/blockilization/rules/game.ts | 4 +- src/games/blockilization/rules/promotions.ts | 24 ++++- src/games/blockilization/rules/units.ts | 93 +++++++++++--------- src/games/blockilization/tests/cases.ts | 70 ++++++++++++--- src/games/blockilization/views.ts | 4 +- 9 files changed, 175 insertions(+), 82 deletions(-) diff --git a/src/games/blockilization/client/concepts.ts b/src/games/blockilization/client/concepts.ts index b3c7403..3b770e5 100644 --- a/src/games/blockilization/client/concepts.ts +++ b/src/games/blockilization/client/concepts.ts @@ -136,8 +136,8 @@ export const CONCEPTS: Article[] = [ color: '#8a5a2a', sub: 'Units of a kind, joined as one', sections: [ - { text: `With [[tech:military_science|Military Science]], two units of the same kind side by side can join as a corps (a fleet, at sea): select one, and Form a corps takes the other into it. With [[tech:replaceable_parts|Replaceable Parts]], a corps and a third unit of its kind join as an army (an armada). A corps fights with ${CORPS_BONUS}% more strength and an army with ${ARMY_BONUS}% more, in attack and defence, as one unit on one tile; its flag is marked II or III.` }, - { title: 'What it costs', text: 'A formation still costs what its units did: their upkeep, their strategic resources, and an upgrade for each of them. Joining spends the unit’s turn. The joined unit has the hit points of both shared out, and the experience and promotions of the more experienced. A formation never splits again; one that can’t be paid for loses a unit at a time. Aircraft, missiles, carriers and civilians don’t form corps.' }, + { text: `With [[tech:military_science|Military Science]], two units of the same kind side by side can join as a corps (a fleet, at sea): select one, and Form a corps takes the other into it. With [[tech:replaceable_parts|Replaceable Parts]], a corps and a third unit of its kind join as an army (an armada). A corps fights with ${CORPS_BONUS}% more strength and an army with ${ARMY_BONUS}% more, in attack and defence, as one unit on one tile; its flag is marked II or III. The bonus multiplies everything else (hills, fortifying, promotions), so it counts in full wherever it fights.` }, + { title: 'What it costs', text: 'A corps’s gold upkeep is half again a unit’s, and an army’s twice a unit’s. It still holds every one of its units’ strategic resources, and its upgrade costs one for each of them. Joining spends the unit’s turn. The joined unit has the hit points of both shared out, and the experience and promotions of the more experienced. A formation never splits again; one that can’t be paid for loses a unit at a time. Aircraft, missiles, carriers and civilians don’t form corps.' }, ], }, { @@ -332,8 +332,9 @@ export const ABILITY_TEXT: Record = { build_sea: 'Builds fishing boats and offshore platforms on sea resources.', ignore_terrain: 'Every tile costs it a single move.', no_terrain_defense: 'No defensive bonus from hills, forest or jungle (their penalties still count).', - bonus_vs_mounted: '+100% strength against mounted units.', - bonus_vs_cities: '+100% strength against cities.', + bonus_vs_mounted: '+50% strength against mounted units.', + bonus_vs_armor: '+100% strength against armour.', + bonus_vs_cities: '+200% strength against cities.', penalty_vs_cities: '-33% strength against cities.', must_setup: 'Must set up (a move) before it can fire.', indirect_fire: 'Indirect Fire: fires over hills, forest, jungle and mountains in the way, at anything your units see.', @@ -346,7 +347,7 @@ export const ABILITY_TEXT: Record = { heal_adjacent: 'Ships with it or next to it heal 15 more.', repair_fleet: 'Repair Fleet: spent, every ship of yours on its tile or next to it is healed fully.', concert_tour: 'Concert Tour: spent in another civ’s land, ten turns of your tourism toward them at once.', - paradrop: 'Can parachute from your land to land you know up to 6 tiles away, if it hasn’t moved.', + paradrop: 'Can parachute from your land to land you know up to 9 tiles away, if it hasn’t moved.', nuke: 'A nuclear weapon: destroys what is round where it lands and leaves fallout on much of the land. Every civ that knows you will long remember it, those it falls on most, and city-states think less of you. An Atomic Bomb (not a missile) may be shot down by a fighter on patrol or an anti-aircraft gun near its target.', spread_religion: 'Spreads its religion to a city.', remove_heresy: 'Drives other religions out of a city.', diff --git a/src/games/blockilization/rules/armies.ts b/src/games/blockilization/rules/armies.ts index 4921a96..687d2c6 100644 --- a/src/games/blockilization/rules/armies.ts +++ b/src/games/blockilization/rules/armies.ts @@ -1,10 +1,13 @@ /** * Corps and armies, Civilization VI's: two units of one kind side by side join as a corps (a fleet, * at sea), and a corps and a third as an army (an armada). It's one unit on the map, much stronger - * than either was (Civ VI's +10 and +17 strength, in this game's terms), that still costs what its - * units did: their upkeep, their strategic resources, its upgrade. Corps come with Military Science, - * armies with Replaceable Parts. A formation never comes apart again (one that can't be paid for loses - * a unit at a time). + * than either was (Civ VI's +10 and +17 strength, in this game's terms: a multiplier of its own, + * `combat.ts`), whose gold upkeep is Civ VI's, half again a unit's for a corps and twice for an army. + * It still holds every one of its units' strategic resources, and pays for each in its upgrade: this + * game ties a resource up for as long as a unit lives, Civ V's way, with no stockpile to pay from as + * Civ VI's units do, so a formation holding only one would free the rest to build more. Corps come + * with Military Science, armies with Replaceable Parts. A formation never comes apart again (one that + * can't be paid for loses a unit at a time). */ import { knows, note, removeUnit } from './core'; import { distance } from './hex'; @@ -21,6 +24,9 @@ export const ARMY_BONUS = 125; /** How many units a unit is (1, or 2 for a corps, 3 for an army). */ export const sizeOf = (u: { size?: number }) => u.size ?? 1; +/** What a unit of this size costs in gold upkeep, in lone units' worth: Civ VI's half again for a corps, twice for an army. */ +export const formationUpkeep = (size: number) => (size >= 3 ? 2 : size === 2 ? 1.5 : 1); + /** The strength a formation of this size adds (percent): 0 for a lone unit. */ export const formationBonus = (size: number) => (size >= 3 ? ARMY_BONUS : size === 2 ? CORPS_BONUS : 0); diff --git a/src/games/blockilization/rules/combat.ts b/src/games/blockilization/rules/combat.ts index f35dad9..e0d5852 100644 --- a/src/games/blockilization/rules/combat.ts +++ b/src/games/blockilization/rules/combat.ts @@ -6,7 +6,7 @@ import type { CombatWhen } from './effects'; import { cityReligion } from './faith'; import { distance, neighbours, ray, within } from './hex'; import { campHooks, civiliansAt, occupants } from './movement'; -import { fromPromotions, PROMOTIONS, type PromotionWhen } from './promotions'; +import { fromPromotions, PROMOTIONS, promotionsOf, type PromotionWhen } from './promotions'; import type { City, Game, Unit } from './state'; import { FALLOUT, FEATURES, HILLS, IMPROVEMENTS, TERRAINS } from './terrain'; import { canFortify, isGreatPerson, isMilitary, UNITS } from './units'; @@ -114,7 +114,9 @@ export interface Modifier { /** * A unit's strength on a tile, for attacking or defending, with everything that changes it (each - * modifier that applies is added to `why`, when given). + * modifier that applies is added to `why`, when given). The modifiers add together, Civ V's way, but + * a corps's or an army's bonus multiplies the rest (Civ VI's flat +10 and +17 never shrink beside + * the others, so a formation keeps its edge fortified on a hill as in the open). */ export function strength(game: Game, u: Unit, role: 'attack' | 'defend' | 'ranged', against: Unit | City | null, at: number, why?: Modifier[]): number { const type = UNITS[u.type]; @@ -127,8 +129,9 @@ export function strength(game: Game, u: Unit, role: 'attack' | 'defend' | 'range why?.push({ label, percent: n }); }; const civ = civEffects(game, p); - // A corps or an army: its units as one. - add(formationKind(u.type, sizeOf(u)) ?? '', formationBonus(sizeOf(u))); + // A corps or an army: its units as one (a multiplier of its own, below). + const formation = formationBonus(sizeOf(u)); + if (formation) why?.push({ label: formationKind(u.type, sizeOf(u))!, percent: formation }); // Wounds: up to half its strength gone at death's door (Civ V's); none for Japan's. if (!has(civ, 'no_wound_penalty')) add('Wounded', -(100 - u.hp) / 2); const tile = game.tiles[at]; @@ -152,10 +155,12 @@ export function strength(game: Game, u: Unit, role: 'attack' | 'defend' | 'range why?.push({ label: 'Embarked', percent: 0 }); } } - // Against mounted: spearmen and pikemen double. Against cities: siege doubles, horses lose a third. - if (vsUnit && UNITS[vsUnit.type].class === 'mounted' && type.abilities?.includes('bonus_vs_mounted')) add('Against mounted', 100); + // Civ V's: spearmen and pikemen +50% against mounted units, anti-tank guns and gunships +100% + // against armour; siege +200% against cities, horses a third less. + if (vsUnit && UNITS[vsUnit.type].class === 'mounted' && type.abilities?.includes('bonus_vs_mounted')) add('Against mounted', 50); + if (vsUnit && UNITS[vsUnit.type].class === 'armor' && type.abilities?.includes('bonus_vs_armor')) add('Against armour', 100); if (vsCity && role !== 'defend') { - if (type.abilities?.includes('bonus_vs_cities')) add('Against cities', 100); + if (type.abilities?.includes('bonus_vs_cities')) add('Against cities', 200); if (type.abilities?.includes('penalty_vs_cities')) add('Against cities', -33); } // A melee attack: +10% for each other unit of the attacker's next to the defender (flanking), -20% @@ -167,8 +172,8 @@ export function strength(game: Game, u: Unit, role: 'attack' | 'defend' | 'range if (game.tiles[target].river && !tile.river && !u.embarked && !amphibious) add('Across a river', -20); if (u.embarked && !amphibious) add('From the sea', -50); } - // Promotions. - for (const id of u.promotions) { + // Promotions (those its kind is born with too). + for (const id of promotionsOf(u)) { const c = PROMOTIONS[id]?.combat; if (c && promotionCounts(c.when, game, role, target, fired, vsUnit, vsCity)) add(PROMOTIONS[id].name, c.percent); } @@ -194,7 +199,7 @@ export function strength(game: Game, u: Unit, role: 'attack' | 'defend' | 'range const mood = happiness(game, p); if (mood < 0) add('Unhappiness', -Math.min(90, -2 * mood)); } - return Math.max(0.5, base * (1 + pct / 100)); + return Math.max(0.5, base * (1 + pct / 100) * (1 + formation / 100)); } /** Whether a promotion's bonus counts in a fight. */ @@ -212,6 +217,8 @@ function promotionCounts(when: PromotionWhen, game: Game, role: 'attack' | 'defe return fired; case 'vs_mounted': return !!vsUnit && UNITS[vsUnit.type].class === 'mounted'; + case 'vs_gun': + return !!vsUnit && UNITS[vsUnit.type].class === 'gun'; case 'vs_land': return !!vsUnit && UNITS[vsUnit.type].domain === 'land'; case 'vs_ships': @@ -220,6 +227,10 @@ function promotionCounts(when: PromotionWhen, game: Game, role: 'attack' | 'defe return !!vsUnit && UNITS[vsUnit.type].domain === 'air'; case 'vs_cities': return !!vsCity && role !== 'defend'; + case 'attacking': + return role !== 'defend'; + case 'vs_wounded': + return role !== 'defend' && !!vsUnit && vsUnit.hp < 100; case 'always': return true; } diff --git a/src/games/blockilization/rules/economy.ts b/src/games/blockilization/rules/economy.ts index 2afbbc4..f5da21d 100644 --- a/src/games/blockilization/rules/economy.ts +++ b/src/games/blockilization/rules/economy.ts @@ -1,4 +1,4 @@ -import { sizeOf } from './armies'; +import { formationUpkeep, sizeOf } from './armies'; import { BUILDINGS } from './buildings'; import { atWar, capitalOf, civEffects, civOf, citiesOf, empireEffects, has, knows, seesResource, speed, unitsOf } from './core'; import type { CityFilter, Effects, GreatKind, SpecialistKind } from './effects'; @@ -535,7 +535,7 @@ export function strategic(game: Game, p: Player): Record !['settler', 'worker', 'work_boat'].includes(u.type) && !isGreatPerson(u.type) && !(garrisonsFree && isMilitary(UNITS[u.type]) && game.cities.some((c) => c.tile === u.tile && c.owner === p.index))) - .reduce((s, u) => s + share(u.type) * sizeOf(u), 0); + .reduce((s, u) => s + share(u.type) * formationUpkeep(sizeOf(u)), 0); // Civ V's unit upkeep grows with the game: a few are free, the rest cost more as the game goes on // (by its turns at quick speed: 1 more each 120, each 180 at standard). const free = 3 + citiesOf(game, p.index).length; diff --git a/src/games/blockilization/rules/game.ts b/src/games/blockilization/rules/game.ts index 160a854..b28cf12 100644 --- a/src/games/blockilization/rules/game.ts +++ b/src/games/blockilization/rules/game.ts @@ -217,7 +217,9 @@ export function trainOptions(game: Game, p: Player, c: City): { id: string; why? if (id === 'settler' && p.cityState) continue; const spare = type.resource ? (res[type.resource]?.have ?? 0) - (res[type.resource]?.used ?? 0) : 1; const full = type.abilities?.includes('trade_route') ? routesFull(game, p) : undefined; - if (spare <= 0) out.push({ id, why: `Needs ${RESOURCES[type.resource!]?.name ?? type.resource} (${res[type.resource!]?.have ? 'all yours is in use' : 'you have none'})` }); + // (Most need one of their resource; a Nuclear Missile two.) + const needs = type.needs ?? 1; + if (spare < needs) out.push({ id, why: `Needs ${needs > 1 ? `${needs} ` : ''}${RESOURCES[type.resource!]?.name ?? type.resource} (${res[type.resource!]?.have ? (spare > 0 ? `you have ${spare} spare` : 'all yours is in use') : 'you have none'})` }); else if (id === 'settler' && c.pop < 2) out.push({ id, why: 'Needs a city of 2 (it takes a citizen)' }); else if (full) out.push({ id, why: full }); else out.push({ id }); diff --git a/src/games/blockilization/rules/promotions.ts b/src/games/blockilization/rules/promotions.ts index 7651d3d..91055f4 100644 --- a/src/games/blockilization/rules/promotions.ts +++ b/src/games/blockilization/rules/promotions.ts @@ -9,7 +9,8 @@ import { UNITS, type UnitClass } from './units'; * Siege against cities, Sentry for sight, a couple for ships, and aircraft's own (Interception, Air * Siege, Bombardment, Air Targeting, Evasion, Air Repair, Air Range). What each does is data * (`combat`, `range`...), which the combat, movement and healing rules read; a few are only given - * (Morale, the Heroic Epic's; Altitude Training, the Cloudcrown's). + * (Morale, the Heroic Epic's; Altitude Training, the Cloudcrown's; and those some unique units are + * born with, Civ V's: the Janissary's, the Cossack's, the Impi's). */ /** When a promotion's combat bonus counts. */ @@ -22,12 +23,16 @@ export type PromotionWhen = | 'ranged_rough' /** Defending against a ranged attack (a unit's or a city's). */ | 'vs_ranged' - /** Fighting mounted units, land units, ships or aircraft; attacking a city. */ + /** Fighting mounted units, gunpowder units, land units, ships or aircraft; attacking a city. */ | 'vs_mounted' + | 'vs_gun' | 'vs_land' | 'vs_ships' | 'vs_aircraft' | 'vs_cities' + /** Attacking (a unit or a city), or attacking a unit that's wounded. */ + | 'attacking' + | 'vs_wounded' | 'always'; export interface Promotion { @@ -106,6 +111,10 @@ export const PROMOTION_LIST: Promotion[] = [ { id: 'air_range', name: 'Air Range', text: '+2 range (and so +4 to rebase).', classes: AIR, range: 2 }, { id: 'morale', name: 'Morale', text: '+15% combat strength (from the Heroic Epic).', classes: [], combat: { percent: 15, when: 'always' } }, { id: 'altitude_training', name: 'Altitude Training', text: '+15% combat strength in rough terrain; heals even after moving or fighting (from the Cloudcrown).', classes: [], combat: { percent: 15, when: 'rough' }, healsOnTheMove: true }, + // Unique units' own (`units.ts`), Civ V's. + { id: 'attack_bonus', name: 'Attack Bonus', text: '+25% combat strength attacking (the Janissary’s).', classes: [], combat: { percent: 25, when: 'attacking' } }, + { id: 'stronger_vs_damaged', name: 'Stronger Against the Wounded', text: '+33% combat strength attacking a wounded unit (the Cossack’s).', classes: [], combat: { percent: 33, when: 'vs_wounded' } }, + { id: 'anti_gunpowder', name: 'Against Gunpowder', text: '+25% combat strength against gunpowder units (the Impi’s).', classes: [], combat: { percent: 25, when: 'vs_gun' } }, ]; export const PROMOTIONS: Record = Object.fromEntries(PROMOTION_LIST.map((p) => [p.id, p])); @@ -113,6 +122,12 @@ export const PROMOTIONS: Record = Object.fromEntries(PROMOTIO /** Experience needed for each promotion: 10, 30, 60, 100, 150, 210... (Civ V's). */ export const xpFor = (n: number) => (n <= 0 ? 10 : 10 + 5 * n * (n + 3)); +/** A unit's promotions: those its kind is born with (`units.ts`; never counted against its experience, and gone if it upgrades), then its own. */ +export function promotionsOf(u: { type: string; promotions: string[] }): string[] { + const born = UNITS[u.type]?.promotions; + return born ? [...born, ...u.promotions.filter((id) => !born.includes(id))] : u.promotions; +} + /** How many promotions a unit can take now (the ones it was given don't count against its experience). */ export function promotionsDue(u: Unit): number { let n = 0; @@ -124,11 +139,12 @@ export function promotionsDue(u: Unit): number { export function promotionsFor(u: Unit): Promotion[] { const cls = UNITS[u.type]?.class; if (!cls) return []; - const has = (id: string) => u.promotions.includes(id); + const had = promotionsOf(u); + const has = (id: string) => had.includes(id); // (Indirect Fire isn't for those that have it already: Artillery, Rocket Artillery, the Battleship.) const indirect = !!UNITS[u.type].abilities?.includes('indirect_fire'); return PROMOTION_LIST.filter((p) => p.classes.includes(cls) && !has(p.id) && (p.needs ?? []).every(has) && (!p.needsOne || p.needsOne.some(has)) && !(p.indirect && indirect)); } /** The sum of a number its promotions add (range, sight, moves, attacks, evasion). */ -export const fromPromotions = (u: Unit, key: 'range' | 'sight' | 'moves' | 'attacks' | 'evasion') => u.promotions.reduce((s, id) => s + (PROMOTIONS[id]?.[key] ?? 0), 0); +export const fromPromotions = (u: Unit, key: 'range' | 'sight' | 'moves' | 'attacks' | 'evasion') => promotionsOf(u).reduce((s, id) => s + (PROMOTIONS[id]?.[key] ?? 0), 0); diff --git a/src/games/blockilization/rules/units.ts b/src/games/blockilization/rules/units.ts index 5b92f9c..dd5b0f6 100644 --- a/src/games/blockilization/rules/units.ts +++ b/src/games/blockilization/rules/units.ts @@ -35,6 +35,8 @@ export type Ability = | 'ignore_terrain' | 'no_terrain_defense' | 'bonus_vs_mounted' + /** +100% against armour (the Anti-Tank Gun's and the Helicopter Gunship's). */ + | 'bonus_vs_armor' | 'bonus_vs_cities' | 'penalty_vs_cities' | 'must_setup' @@ -74,6 +76,7 @@ export type Ability = /** Attacks from the sea and across rivers as from the land (Denmark's Berserker). */ | 'amphibious' | 'political_treatise' + /** Moves on after a melee attack (Civ V's mounted units and armour). */ | 'withdraw'; export interface UnitType { @@ -91,7 +94,11 @@ export interface UnitType { obsolete?: string; upgrade?: string; resource?: string; + /** How many of that resource it takes (1 unless said: the Nuclear Missile's 2, Civ V's). */ + needs?: number; abilities?: Ability[]; + /** Promotions it's born with (Civ V's, `promotions.ts`): not counted against its experience, and lost if it upgrades. */ + promotions?: string[]; /** A civ's own unit, and the one it replaces. */ civ?: string; replaces?: string; @@ -105,14 +112,14 @@ export interface UnitType { const U = (u: Omit & { domain?: Domain }): UnitType => ({ domain: 'land', ...u }); -/** How far a paratrooper drops (Civ V's 9, on maps about half as wide). */ -export const PARADROP_RANGE = 6; +/** How far a paratrooper drops (Civ V's). */ +export const PARADROP_RANGE = 9; export const UNIT_LIST: UnitType[] = [ // ----- Civilians U({ id: 'settler', name: 'Settler', class: 'civilian', cost: 106, strength: 0, moves: 2, abilities: ['found_city', 'no_attack'] }), U({ id: 'worker', name: 'Worker', class: 'civilian', cost: 70, strength: 0, moves: 2, abilities: ['build', 'no_attack'] }), - U({ id: 'work_boat', name: 'Work Boat', class: 'civilian', domain: 'sea', cost: 59, strength: 0, moves: 4, tech: 'sailing', abilities: ['build_sea', 'no_attack', 'coast_only'] }), + U({ id: 'work_boat', name: 'Work Boat', class: 'civilian', domain: 'sea', cost: 50, strength: 0, moves: 4, tech: 'sailing', abilities: ['build_sea', 'no_attack', 'coast_only'] }), U({ id: 'caravan', name: 'Caravan', class: 'trade', cost: 75, strength: 0, moves: 1, tech: 'animal_husbandry', abilities: ['trade_route', 'no_attack'] }), U({ id: 'cargo_ship', name: 'Cargo Ship', class: 'trade', domain: 'sea', cost: 100, strength: 0, moves: 1, tech: 'sailing', abilities: ['trade_route', 'no_attack'] }), U({ id: 'missionary', name: 'Missionary', class: 'religious', cost: 0, strength: 0, moves: 4, abilities: ['spread_religion', 'no_attack'], unbuildable: true }), @@ -130,16 +137,16 @@ export const UNIT_LIST: UnitType[] = [ U({ id: 'great_admiral', name: 'Great Admiral', class: 'great', domain: 'sea', cost: 0, strength: 0, moves: 5, abilities: ['combat_bonus_aura', 'heal_adjacent', 'repair_fleet', 'no_attack'], unbuildable: true }), // ----- Ancient - U({ id: 'scout', name: 'Scout', class: 'recon', cost: 25, strength: 4, moves: 2, obsolete: 'scientific_theory', upgrade: 'explorer', abilities: ['ignore_terrain'] }), + U({ id: 'scout', name: 'Scout', class: 'recon', cost: 25, strength: 5, moves: 2, obsolete: 'scientific_theory', upgrade: 'explorer', abilities: ['ignore_terrain'] }), U({ id: 'warrior', name: 'Warrior', class: 'melee', cost: 40, strength: 8, moves: 2, obsolete: 'metal_casting', upgrade: 'swordsman' }), U({ id: 'archer', name: 'Archer', class: 'ranged', cost: 40, strength: 5, ranged: 7, range: 2, moves: 2, tech: 'archery', obsolete: 'machinery', upgrade: 'composite_bowman' }), U({ id: 'spearman', name: 'Spearman', class: 'melee', cost: 56, strength: 11, moves: 2, tech: 'bronze_working', obsolete: 'civil_service', upgrade: 'pikeman', abilities: ['bonus_vs_mounted'] }), U({ id: 'chariot_archer', name: 'Chariot Archer', class: 'mounted', cost: 56, strength: 6, ranged: 10, range: 2, moves: 4, tech: 'the_wheel', obsolete: 'chivalry', upgrade: 'knight', resource: 'horses', abilities: ['no_terrain_defense'] }), - U({ id: 'trireme', name: 'Trireme', class: 'naval_melee', domain: 'sea', cost: 56, strength: 10, moves: 4, tech: 'sailing', obsolete: 'astronomy', upgrade: 'caravel', abilities: ['coast_only'] }), + U({ id: 'trireme', name: 'Trireme', class: 'naval_melee', domain: 'sea', cost: 45, strength: 10, moves: 4, tech: 'sailing', obsolete: 'astronomy', upgrade: 'caravel', abilities: ['coast_only'] }), // ----- Classical U({ id: 'horseman', name: 'Horseman', class: 'mounted', cost: 75, strength: 12, moves: 4, tech: 'horseback_riding', obsolete: 'chivalry', upgrade: 'knight', resource: 'horses', abilities: ['no_terrain_defense', 'penalty_vs_cities', 'withdraw'] }), U({ id: 'composite_bowman', name: 'Composite Bowman', class: 'ranged', cost: 75, strength: 7, ranged: 11, range: 2, moves: 2, tech: 'construction', obsolete: 'machinery', upgrade: 'crossbowman' }), - U({ id: 'catapult', name: 'Catapult', class: 'siege', cost: 75, strength: 7, ranged: 8, range: 2, moves: 2, tech: 'mathematics', obsolete: 'physics', upgrade: 'trebuchet', abilities: ['bonus_vs_cities', 'must_setup'] }), + U({ id: 'catapult', name: 'Catapult', class: 'siege', cost: 75, strength: 7, ranged: 8, range: 2, moves: 2, tech: 'mathematics', obsolete: 'physics', upgrade: 'trebuchet', sight: 1, abilities: ['bonus_vs_cities', 'must_setup', 'no_terrain_defense'] }), U({ id: 'swordsman', name: 'Swordsman', class: 'melee', cost: 75, strength: 14, moves: 2, tech: 'iron_working', obsolete: 'steel', upgrade: 'longswordsman', resource: 'iron' }), U({ id: 'galleass', name: 'Galleass', class: 'naval_ranged', domain: 'sea', cost: 100, strength: 16, ranged: 17, range: 2, moves: 3, tech: 'compass', obsolete: 'navigation', upgrade: 'frigate', abilities: ['coast_only'] }), // ----- Medieval @@ -147,12 +154,12 @@ export const UNIT_LIST: UnitType[] = [ U({ id: 'knight', name: 'Knight', class: 'mounted', cost: 120, strength: 20, moves: 4, tech: 'chivalry', obsolete: 'military_science', upgrade: 'cavalry', resource: 'horses', abilities: ['no_terrain_defense', 'penalty_vs_cities', 'withdraw'] }), U({ id: 'crossbowman', name: 'Crossbowman', class: 'ranged', cost: 120, strength: 13, ranged: 18, range: 2, moves: 2, tech: 'machinery', obsolete: 'industrialization', upgrade: 'gatling_gun' }), U({ id: 'longswordsman', name: 'Longswordsman', class: 'melee', cost: 120, strength: 21, moves: 2, tech: 'steel', obsolete: 'gunpowder', upgrade: 'musketman', resource: 'iron' }), - U({ id: 'trebuchet', name: 'Trebuchet', class: 'siege', cost: 120, strength: 12, ranged: 14, range: 2, moves: 2, tech: 'physics', obsolete: 'chemistry', upgrade: 'cannon', abilities: ['bonus_vs_cities', 'must_setup'] }), + U({ id: 'trebuchet', name: 'Trebuchet', class: 'siege', cost: 120, strength: 12, ranged: 14, range: 2, moves: 2, tech: 'physics', obsolete: 'chemistry', upgrade: 'cannon', sight: 1, abilities: ['bonus_vs_cities', 'must_setup', 'no_terrain_defense'] }), U({ id: 'caravel', name: 'Caravel', class: 'naval_melee', domain: 'sea', cost: 120, strength: 20, moves: 4, tech: 'astronomy', obsolete: 'combustion', upgrade: 'ironclad', abilities: ['extra_sight'] }), // ----- Renaissance U({ id: 'musketman', name: 'Musketman', class: 'gun', cost: 150, strength: 24, moves: 2, tech: 'gunpowder', obsolete: 'rifling', upgrade: 'rifleman' }), - U({ id: 'lancer', name: 'Lancer', class: 'mounted', cost: 185, strength: 25, moves: 4, tech: 'metallurgy', obsolete: 'combined_arms', upgrade: 'anti_tank_gun', resource: 'horses', abilities: ['no_terrain_defense', 'penalty_vs_cities', 'bonus_vs_mounted'] }), - U({ id: 'cannon', name: 'Cannon', class: 'siege', cost: 185, strength: 14, ranged: 20, range: 2, moves: 2, tech: 'chemistry', obsolete: 'dynamite', upgrade: 'artillery', abilities: ['bonus_vs_cities', 'must_setup'] }), + U({ id: 'lancer', name: 'Lancer', class: 'mounted', cost: 185, strength: 25, moves: 4, tech: 'metallurgy', obsolete: 'combined_arms', upgrade: 'anti_tank_gun', resource: 'horses', abilities: ['no_terrain_defense', 'penalty_vs_cities', 'withdraw'], promotions: ['formation_1'] }), + U({ id: 'cannon', name: 'Cannon', class: 'siege', cost: 185, strength: 14, ranged: 20, range: 2, moves: 2, tech: 'chemistry', obsolete: 'dynamite', upgrade: 'artillery', sight: 1, abilities: ['bonus_vs_cities', 'must_setup', 'no_terrain_defense'] }), U({ id: 'frigate', name: 'Frigate', class: 'naval_ranged', domain: 'sea', cost: 185, strength: 25, ranged: 28, range: 2, moves: 5, tech: 'navigation', obsolete: 'electronics', upgrade: 'battleship', resource: 'iron' }), U({ id: 'privateer', name: 'Privateer', class: 'naval_melee', domain: 'sea', cost: 150, strength: 25, moves: 5, tech: 'navigation', obsolete: 'combustion', upgrade: 'destroyer' }), // ----- Industrial @@ -160,72 +167,72 @@ export const UNIT_LIST: UnitType[] = [ U({ id: 'rifleman', name: 'Rifleman', class: 'gun', cost: 225, strength: 34, moves: 2, tech: 'rifling', obsolete: 'replaceable_parts', upgrade: 'great_war_infantry' }), U({ id: 'cavalry', name: 'Cavalry', class: 'mounted', cost: 225, strength: 34, moves: 4, tech: 'military_science', obsolete: 'combustion', upgrade: 'landship', resource: 'horses', abilities: ['no_terrain_defense', 'penalty_vs_cities', 'withdraw'] }), U({ id: 'gatling_gun', name: 'Gatling Gun', class: 'ranged', cost: 225, strength: 30, ranged: 30, range: 1, moves: 2, tech: 'industrialization', obsolete: 'ballistics', upgrade: 'machine_gun' }), - U({ id: 'artillery', name: 'Artillery', class: 'siege', cost: 250, strength: 21, ranged: 28, range: 3, moves: 2, tech: 'dynamite', obsolete: 'rocketry', upgrade: 'rocket_artillery', abilities: ['bonus_vs_cities', 'must_setup', 'indirect_fire'] }), - U({ id: 'ironclad', name: 'Ironclad', class: 'naval_melee', domain: 'sea', cost: 250, strength: 45, moves: 4, tech: 'steam_power', obsolete: 'combustion', upgrade: 'destroyer', resource: 'coal', abilities: ['coast_only'] }), + U({ id: 'artillery', name: 'Artillery', class: 'siege', cost: 250, strength: 21, ranged: 28, range: 3, moves: 2, tech: 'dynamite', obsolete: 'rocketry', upgrade: 'rocket_artillery', sight: 1, abilities: ['bonus_vs_cities', 'must_setup', 'indirect_fire', 'no_terrain_defense'] }), + U({ id: 'ironclad', name: 'Ironclad', class: 'naval_melee', domain: 'sea', cost: 250, strength: 45, moves: 3, tech: 'steam_power', obsolete: 'combustion', upgrade: 'destroyer', resource: 'coal', abilities: ['coast_only'] }), U({ id: 'great_war_infantry', name: 'Great War Infantry', class: 'gun', cost: 320, strength: 50, moves: 2, tech: 'replaceable_parts', obsolete: 'plastics', upgrade: 'infantry' }), U({ id: 'triplane', name: 'Triplane', class: 'fighter', domain: 'air', cost: 325, strength: 0, ranged: 35, range: 5, moves: 1, tech: 'flight', obsolete: 'radar', upgrade: 'fighter', resource: 'oil' }), U({ id: 'great_war_bomber', name: 'Great War Bomber', class: 'bomber', domain: 'air', cost: 325, strength: 0, ranged: 50, range: 6, moves: 1, tech: 'flight', obsolete: 'radar', upgrade: 'bomber', resource: 'oil' }), // ----- Modern U({ id: 'infantry', name: 'Infantry', class: 'gun', cost: 375, strength: 70, moves: 2, tech: 'plastics', obsolete: 'mobile_tactics', upgrade: 'mechanized_infantry' }), U({ id: 'machine_gun', name: 'Machine Gun', class: 'ranged', cost: 350, strength: 60, ranged: 60, range: 1, moves: 2, tech: 'ballistics', obsolete: 'nuclear_fission', upgrade: 'bazooka' }), - U({ id: 'anti_aircraft_gun', name: 'Anti-Aircraft Gun', class: 'gun', cost: 300, strength: 50, moves: 2, tech: 'ballistics', obsolete: 'rocketry', upgrade: 'mobile_sam', abilities: ['intercept'] }), - U({ id: 'landship', name: 'Landship', class: 'armor', cost: 350, strength: 60, moves: 4, tech: 'combustion', obsolete: 'combined_arms', upgrade: 'tank', resource: 'oil', abilities: ['no_terrain_defense', 'penalty_vs_cities'] }), + U({ id: 'anti_aircraft_gun', name: 'Anti-Aircraft Gun', class: 'gun', cost: 375, strength: 50, moves: 2, tech: 'ballistics', obsolete: 'rocketry', upgrade: 'mobile_sam', abilities: ['intercept'] }), + U({ id: 'landship', name: 'Landship', class: 'armor', cost: 350, strength: 60, moves: 4, tech: 'combustion', obsolete: 'combined_arms', upgrade: 'tank', resource: 'oil', abilities: ['no_terrain_defense', 'withdraw'] }), U({ id: 'destroyer', name: 'Destroyer', class: 'naval_melee', domain: 'sea', cost: 375, strength: 55, moves: 6, tech: 'combustion', abilities: ['see_submarines'] }), U({ id: 'battleship', name: 'Battleship', class: 'naval_ranged', domain: 'sea', cost: 375, strength: 55, ranged: 65, range: 3, moves: 5, tech: 'electronics', resource: 'oil', abilities: ['indirect_fire'] }), - U({ id: 'submarine', name: 'Submarine', class: 'submarine', domain: 'sea', cost: 325, strength: 35, ranged: 60, range: 1, moves: 5, tech: 'refrigeration', obsolete: 'telecommunications', upgrade: 'nuclear_submarine', abilities: ['invisible'] }), + U({ id: 'submarine', name: 'Submarine', class: 'submarine', domain: 'sea', cost: 325, strength: 35, ranged: 60, range: 2, moves: 5, tech: 'refrigeration', obsolete: 'telecommunications', upgrade: 'nuclear_submarine', abilities: ['invisible'] }), U({ id: 'carrier', name: 'Carrier', class: 'carrier', domain: 'sea', cost: 375, strength: 40, moves: 5, tech: 'electronics' }), - U({ id: 'anti_tank_gun', name: 'Anti-Tank Gun', class: 'gun', cost: 300, strength: 50, moves: 2, tech: 'combined_arms', obsolete: 'computers', upgrade: 'helicopter_gunship' }), + U({ id: 'anti_tank_gun', name: 'Anti-Tank Gun', class: 'gun', cost: 300, strength: 50, moves: 2, tech: 'combined_arms', obsolete: 'computers', upgrade: 'helicopter_gunship', abilities: ['bonus_vs_armor'] }), // ----- Atomic U({ id: 'fighter', name: 'Fighter', class: 'fighter', domain: 'air', cost: 375, strength: 0, ranged: 45, range: 8, moves: 1, tech: 'radar', obsolete: 'lasers', upgrade: 'jet_fighter', resource: 'oil' }), U({ id: 'bomber', name: 'Bomber', class: 'bomber', domain: 'air', cost: 375, strength: 0, ranged: 65, range: 10, moves: 1, tech: 'radar', obsolete: 'stealth', upgrade: 'stealth_bomber', resource: 'oil' }), U({ id: 'paratrooper', name: 'Paratrooper', class: 'gun', cost: 375, strength: 65, moves: 2, tech: 'radar', upgrade: 'xcom_squad', abilities: ['paradrop'] }), - U({ id: 'tank', name: 'Tank', class: 'armor', cost: 375, strength: 70, moves: 5, tech: 'combined_arms', obsolete: 'lasers', upgrade: 'modern_armor', resource: 'oil', abilities: ['no_terrain_defense', 'penalty_vs_cities'] }), - U({ id: 'bazooka', name: 'Bazooka', class: 'gun', cost: 375, strength: 85, moves: 2, tech: 'nuclear_fission' }), - U({ id: 'mobile_sam', name: 'Mobile SAM', class: 'gun', cost: 425, strength: 65, moves: 4, tech: 'rocketry', abilities: ['intercept'] }), + U({ id: 'tank', name: 'Tank', class: 'armor', cost: 375, strength: 70, moves: 5, tech: 'combined_arms', obsolete: 'lasers', upgrade: 'modern_armor', resource: 'oil', abilities: ['no_terrain_defense', 'withdraw'] }), + U({ id: 'bazooka', name: 'Bazooka', class: 'ranged', cost: 375, strength: 85, ranged: 85, range: 1, moves: 2, tech: 'nuclear_fission' }), + U({ id: 'mobile_sam', name: 'Mobile SAM', class: 'gun', cost: 425, strength: 65, moves: 3, tech: 'rocketry', abilities: ['intercept'] }), U({ id: 'atomic_bomb', name: 'Atomic Bomb', class: 'missile', domain: 'air', cost: 600, strength: 0, ranged: 0, range: 10, moves: 1, tech: 'nuclear_fission', resource: 'uranium', abilities: ['nuke'], blast: 1 }), U({ id: 'guided_missile', name: 'Guided Missile', class: 'missile', domain: 'air', cost: 150, strength: 0, ranged: 60, range: 8, moves: 1, tech: 'advanced_ballistics' }), - U({ id: 'rocket_artillery', name: 'Rocket Artillery', class: 'siege', cost: 425, strength: 45, ranged: 60, range: 3, moves: 3, tech: 'rocketry', resource: 'aluminum', abilities: ['bonus_vs_cities', 'indirect_fire'] }), + U({ id: 'rocket_artillery', name: 'Rocket Artillery', class: 'siege', cost: 425, strength: 45, ranged: 60, range: 3, moves: 2, tech: 'rocketry', resource: 'aluminum', sight: 1, abilities: ['bonus_vs_cities', 'indirect_fire', 'no_terrain_defense'] }), // ----- Information - U({ id: 'helicopter_gunship', name: 'Helicopter Gunship', class: 'helicopter', cost: 425, strength: 60, moves: 6, tech: 'computers', resource: 'aluminum', abilities: ['ignore_terrain'] }), + U({ id: 'helicopter_gunship', name: 'Helicopter Gunship', class: 'helicopter', cost: 425, strength: 60, moves: 6, tech: 'computers', resource: 'aluminum', abilities: ['ignore_terrain', 'bonus_vs_armor'] }), U({ id: 'mechanized_infantry', name: 'Mechanized Infantry', class: 'gun', cost: 375, strength: 90, moves: 3, tech: 'mobile_tactics' }), - U({ id: 'nuclear_submarine', name: 'Nuclear Submarine', class: 'submarine', domain: 'sea', cost: 425, strength: 50, ranged: 85, range: 1, moves: 6, tech: 'telecommunications', abilities: ['invisible'] }), + U({ id: 'nuclear_submarine', name: 'Nuclear Submarine', class: 'submarine', domain: 'sea', cost: 425, strength: 50, ranged: 85, range: 3, moves: 6, tech: 'telecommunications', abilities: ['invisible'] }), U({ id: 'missile_cruiser', name: 'Missile Cruiser', class: 'naval_ranged', domain: 'sea', cost: 425, strength: 80, ranged: 100, range: 3, moves: 7, tech: 'robotics', abilities: ['see_submarines'] }), - U({ id: 'modern_armor', name: 'Modern Armor', class: 'armor', cost: 425, strength: 100, moves: 5, tech: 'lasers', upgrade: 'giant_death_robot', resource: 'aluminum', abilities: ['no_terrain_defense', 'penalty_vs_cities'] }), - U({ id: 'jet_fighter', name: 'Jet Fighter', class: 'fighter', domain: 'air', cost: 425, strength: 0, ranged: 80, range: 10, moves: 1, tech: 'lasers', resource: 'aluminum' }), + U({ id: 'modern_armor', name: 'Modern Armor', class: 'armor', cost: 425, strength: 100, moves: 5, tech: 'lasers', upgrade: 'giant_death_robot', resource: 'aluminum', abilities: ['no_terrain_defense', 'withdraw'] }), + U({ id: 'jet_fighter', name: 'Jet Fighter', class: 'fighter', domain: 'air', cost: 425, strength: 0, ranged: 75, range: 10, moves: 1, tech: 'lasers', resource: 'aluminum' }), U({ id: 'stealth_bomber', name: 'Stealth Bomber', class: 'bomber', domain: 'air', cost: 425, strength: 0, ranged: 85, range: 20, moves: 1, tech: 'stealth', resource: 'aluminum' }), - U({ id: 'nuclear_missile', name: 'Nuclear Missile', class: 'missile', domain: 'air', cost: 1000, strength: 0, ranged: 0, range: 12, moves: 1, tech: 'advanced_ballistics', resource: 'uranium', abilities: ['nuke'], blast: 2 }), - U({ id: 'xcom_squad', name: 'XCOM Squad', class: 'gun', cost: 400, strength: 110, moves: 2, tech: 'nanotechnology', abilities: ['paradrop', 'ignore_terrain'] }), - U({ id: 'giant_death_robot', name: 'Giant Death Robot', class: 'armor', cost: 425, strength: 150, moves: 5, tech: 'nuclear_fusion', resource: 'uranium', abilities: ['no_terrain_defense', 'penalty_vs_cities'] }), + U({ id: 'nuclear_missile', name: 'Nuclear Missile', class: 'missile', domain: 'air', cost: 1000, strength: 0, ranged: 0, range: 12, moves: 1, tech: 'advanced_ballistics', resource: 'uranium', needs: 2, abilities: ['nuke'], blast: 2 }), + U({ id: 'xcom_squad', name: 'XCOM Squad', class: 'gun', cost: 400, strength: 100, moves: 2, tech: 'nanotechnology', abilities: ['paradrop', 'ignore_terrain'] }), + U({ id: 'giant_death_robot', name: 'Giant Death Robot', class: 'armor', cost: 550, strength: 150, moves: 5, tech: 'nuclear_fusion', resource: 'uranium', abilities: ['no_terrain_defense', 'withdraw'] }), // ----- Unique units (`civs.ts`) U({ id: 'war_chariot', name: 'War Chariot', class: 'mounted', cost: 56, strength: 6, ranged: 10, range: 2, moves: 5, tech: 'the_wheel', obsolete: 'chivalry', upgrade: 'knight', civ: 'egypt', replaces: 'chariot_archer', abilities: ['no_terrain_defense'] }), - U({ id: 'bowman', name: 'Bowman', class: 'ranged', cost: 40, strength: 6, ranged: 9, range: 2, moves: 2, tech: 'archery', obsolete: 'machinery', upgrade: 'composite_bowman', civ: 'babylon', replaces: 'archer' }), + U({ id: 'bowman', name: 'Bowman', class: 'ranged', cost: 40, strength: 7, ranged: 9, range: 2, moves: 2, tech: 'archery', obsolete: 'machinery', upgrade: 'composite_bowman', civ: 'babylon', replaces: 'archer' }), U({ id: 'chu_ko_nu', name: 'Chu-Ko-Nu', class: 'ranged', cost: 120, strength: 13, ranged: 14, range: 2, moves: 2, tech: 'machinery', obsolete: 'industrialization', upgrade: 'gatling_gun', civ: 'china', replaces: 'crossbowman' }), - U({ id: 'war_elephant', name: 'War Elephant', class: 'mounted', cost: 75, strength: 9, ranged: 11, range: 2, moves: 3, tech: 'the_wheel', obsolete: 'chivalry', upgrade: 'knight', civ: 'india', replaces: 'chariot_archer', abilities: ['no_terrain_defense'] }), + U({ id: 'war_elephant', name: 'War Elephant', class: 'mounted', cost: 70, strength: 9, ranged: 11, range: 2, moves: 3, tech: 'the_wheel', obsolete: 'chivalry', upgrade: 'knight', civ: 'india', replaces: 'chariot_archer', abilities: ['no_terrain_defense'] }), U({ id: 'immortal', name: 'Immortal', class: 'melee', cost: 56, strength: 12, moves: 2, tech: 'bronze_working', obsolete: 'civil_service', upgrade: 'pikeman', civ: 'persia', replaces: 'spearman', abilities: ['bonus_vs_mounted'] }), U({ id: 'turtle_ship', name: 'Turtle Ship', class: 'naval_melee', domain: 'sea', cost: 120, strength: 36, moves: 4, tech: 'astronomy', obsolete: 'combustion', upgrade: 'ironclad', civ: 'korea', replaces: 'caravel', abilities: ['coast_only'] }), - U({ id: 'camel_archer', name: 'Camel Archer', class: 'mounted', cost: 120, strength: 21, ranged: 21, range: 2, moves: 4, tech: 'chivalry', obsolete: 'military_science', upgrade: 'cavalry', resource: 'horses', civ: 'arabia', replaces: 'knight', abilities: ['no_terrain_defense', 'withdraw'] }), - U({ id: 'landsknecht', name: 'Landsknecht', class: 'melee', cost: 60, strength: 16, moves: 2, tech: 'civil_service', obsolete: 'metallurgy', upgrade: 'lancer', civ: 'germany', replaces: 'pikeman', abilities: ['bonus_vs_mounted'] }), + U({ id: 'camel_archer', name: 'Camel Archer', class: 'mounted', cost: 120, strength: 17, ranged: 21, range: 2, moves: 4, tech: 'chivalry', obsolete: 'military_science', upgrade: 'cavalry', resource: 'horses', civ: 'arabia', replaces: 'knight', abilities: ['no_terrain_defense', 'withdraw'] }), + U({ id: 'landsknecht', name: 'Landsknecht', class: 'melee', cost: 45, strength: 16, moves: 2, tech: 'civil_service', obsolete: 'metallurgy', upgrade: 'lancer', civ: 'germany', replaces: 'pikeman', abilities: ['bonus_vs_mounted'] }), U({ id: 'mohawk_warrior', name: 'Mohawk Warrior', class: 'melee', cost: 75, strength: 14, moves: 2, tech: 'iron_working', obsolete: 'steel', upgrade: 'longswordsman', civ: 'iroquois', replaces: 'swordsman' }), - U({ id: 'jaguar', name: 'Jaguar', class: 'melee', cost: 40, strength: 9, moves: 2, obsolete: 'metal_casting', upgrade: 'swordsman', civ: 'aztec', replaces: 'warrior', abilities: ['kill_heal', 'jungle_fighter'] }), - U({ id: 'cossack', name: 'Cossack', class: 'mounted', cost: 225, strength: 36, moves: 4, tech: 'military_science', obsolete: 'combustion', upgrade: 'landship', resource: 'horses', civ: 'russia', replaces: 'cavalry', abilities: ['no_terrain_defense', 'withdraw'] }), + U({ id: 'jaguar', name: 'Jaguar', class: 'melee', cost: 40, strength: 8, moves: 2, obsolete: 'metal_casting', upgrade: 'swordsman', civ: 'aztec', replaces: 'warrior', abilities: ['kill_heal', 'jungle_fighter'] }), + U({ id: 'cossack', name: 'Cossack', class: 'mounted', cost: 225, strength: 34, moves: 4, tech: 'military_science', obsolete: 'combustion', upgrade: 'landship', resource: 'horses', civ: 'russia', replaces: 'cavalry', abilities: ['no_terrain_defense', 'penalty_vs_cities', 'withdraw'], promotions: ['stronger_vs_damaged'] }), U({ id: 'musketeer', name: 'Musketeer', class: 'gun', cost: 150, strength: 28, moves: 2, tech: 'gunpowder', obsolete: 'rifling', upgrade: 'rifleman', civ: 'france', replaces: 'musketman' }), U({ id: 'legion', name: 'Legion', class: 'melee', cost: 75, strength: 17, moves: 2, tech: 'iron_working', obsolete: 'steel', upgrade: 'longswordsman', resource: 'iron', civ: 'rome', replaces: 'swordsman' }), U({ id: 'hoplite', name: 'Hoplite', class: 'melee', cost: 56, strength: 13, moves: 2, tech: 'bronze_working', obsolete: 'civil_service', upgrade: 'pikeman', civ: 'greece', replaces: 'spearman', abilities: ['bonus_vs_mounted'] }), - U({ id: 'samurai', name: 'Samurai', class: 'melee', cost: 120, strength: 23, moves: 2, tech: 'steel', obsolete: 'gunpowder', upgrade: 'musketman', resource: 'iron', civ: 'japan', replaces: 'longswordsman' }), + U({ id: 'samurai', name: 'Samurai', class: 'melee', cost: 120, strength: 21, moves: 2, tech: 'steel', obsolete: 'gunpowder', upgrade: 'musketman', resource: 'iron', civ: 'japan', replaces: 'longswordsman', promotions: ['shock_1'] }), U({ id: 'longbowman', name: 'Longbowman', class: 'ranged', cost: 120, strength: 13, ranged: 18, range: 3, moves: 2, tech: 'machinery', obsolete: 'industrialization', upgrade: 'gatling_gun', civ: 'england', replaces: 'crossbowman' }), U({ id: 'keshik', name: 'Keshik', class: 'mounted', cost: 120, strength: 15, ranged: 16, range: 2, moves: 5, tech: 'chivalry', obsolete: 'military_science', upgrade: 'cavalry', resource: 'horses', civ: 'mongolia', replaces: 'knight', abilities: ['no_terrain_defense'] }), - U({ id: 'janissary', name: 'Janissary', class: 'gun', cost: 150, strength: 26, moves: 2, tech: 'gunpowder', obsolete: 'rifling', upgrade: 'rifleman', civ: 'ottomans', replaces: 'musketman', abilities: ['kill_heal'] }), - U({ id: 'impi', name: 'Impi', class: 'melee', cost: 90, strength: 16, moves: 3, tech: 'civil_service', obsolete: 'metallurgy', upgrade: 'lancer', civ: 'zulu', replaces: 'pikeman', abilities: ['bonus_vs_mounted'] }), - U({ id: 'slinger', name: 'Slinger', class: 'ranged', cost: 35, strength: 5, ranged: 7, range: 2, moves: 2, tech: 'archery', obsolete: 'machinery', upgrade: 'composite_bowman', civ: 'inca', replaces: 'archer' }), + U({ id: 'janissary', name: 'Janissary', class: 'gun', cost: 150, strength: 24, moves: 2, tech: 'gunpowder', obsolete: 'rifling', upgrade: 'rifleman', civ: 'ottomans', replaces: 'musketman', abilities: ['kill_heal'], promotions: ['attack_bonus'] }), + U({ id: 'impi', name: 'Impi', class: 'melee', cost: 90, strength: 16, moves: 2, tech: 'civil_service', obsolete: 'metallurgy', upgrade: 'lancer', civ: 'zulu', replaces: 'pikeman', abilities: ['bonus_vs_mounted'], promotions: ['anti_gunpowder'] }), + U({ id: 'slinger', name: 'Slinger', class: 'ranged', cost: 40, strength: 4, ranged: 7, range: 2, moves: 2, tech: 'archery', obsolete: 'machinery', upgrade: 'composite_bowman', civ: 'inca', replaces: 'archer' }), U({ id: 'minuteman', name: 'Minuteman', class: 'gun', cost: 150, strength: 24, moves: 2, tech: 'gunpowder', obsolete: 'rifling', upgrade: 'rifleman', civ: 'america', replaces: 'musketman', abilities: ['ignore_terrain'] }), - U({ id: 'conquistador', name: 'Conquistador', class: 'mounted', cost: 120, strength: 20, moves: 4, tech: 'chivalry', obsolete: 'military_science', upgrade: 'cavalry', resource: 'horses', civ: 'spain', replaces: 'knight', abilities: ['no_terrain_defense', 'withdraw', 'extra_sight'] }), - U({ id: 'naresuans_elephant', name: "Naresuan's Elephant", class: 'mounted', cost: 120, strength: 25, moves: 3, tech: 'chivalry', obsolete: 'military_science', upgrade: 'cavalry', civ: 'siam', replaces: 'knight', abilities: ['no_terrain_defense'] }), - U({ id: 'mehal_sefari', name: 'Mehal Sefari', class: 'gun', cost: 225, strength: 34, moves: 2, tech: 'rifling', obsolete: 'replaceable_parts', upgrade: 'great_war_infantry', civ: 'ethiopia', replaces: 'rifleman', abilities: ['homeland'] }), - U({ id: 'pictish_warrior', name: 'Pictish Warrior', class: 'melee', cost: 56, strength: 11, moves: 2, tech: 'bronze_working', obsolete: 'civil_service', upgrade: 'pikeman', civ: 'celts', replaces: 'spearman', abilities: ['bonus_vs_mounted', 'foreign_lands'] }), - U({ id: 'quinquereme', name: 'Quinquereme', class: 'naval_melee', domain: 'sea', cost: 56, strength: 13, moves: 4, tech: 'sailing', obsolete: 'astronomy', upgrade: 'caravel', civ: 'carthage', replaces: 'trireme', abilities: ['coast_only'] }), - U({ id: 'winged_hussar', name: 'Winged Hussar', class: 'mounted', cost: 185, strength: 30, moves: 5, tech: 'metallurgy', obsolete: 'combined_arms', upgrade: 'anti_tank_gun', resource: 'horses', civ: 'poland', replaces: 'lancer', abilities: ['no_terrain_defense', 'penalty_vs_cities', 'bonus_vs_mounted'] }), - U({ id: 'sea_beggar', name: 'Sea Beggar', class: 'naval_melee', domain: 'sea', cost: 150, strength: 27, moves: 5, tech: 'navigation', obsolete: 'combustion', upgrade: 'destroyer', civ: 'netherlands', replaces: 'privateer', abilities: ['heal_adjacent'] }), + U({ id: 'conquistador', name: 'Conquistador', class: 'mounted', cost: 135, strength: 20, moves: 4, tech: 'chivalry', obsolete: 'military_science', upgrade: 'cavalry', resource: 'horses', civ: 'spain', replaces: 'knight', abilities: ['no_terrain_defense', 'withdraw', 'extra_sight'] }), + U({ id: 'naresuans_elephant', name: "Naresuan's Elephant", class: 'mounted', cost: 120, strength: 25, moves: 3, tech: 'chivalry', obsolete: 'military_science', upgrade: 'cavalry', civ: 'siam', replaces: 'knight', abilities: ['no_terrain_defense', 'bonus_vs_mounted', 'penalty_vs_cities', 'withdraw'] }), + U({ id: 'mehal_sefari', name: 'Mehal Sefari', class: 'gun', cost: 200, strength: 34, moves: 2, tech: 'rifling', obsolete: 'replaceable_parts', upgrade: 'great_war_infantry', civ: 'ethiopia', replaces: 'rifleman', abilities: ['homeland'] }), + U({ id: 'pictish_warrior', name: 'Pictish Warrior', class: 'melee', cost: 56, strength: 11, moves: 2, tech: 'bronze_working', obsolete: 'civil_service', upgrade: 'pikeman', civ: 'celts', replaces: 'spearman', abilities: ['foreign_lands'] }), + U({ id: 'quinquereme', name: 'Quinquereme', class: 'naval_melee', domain: 'sea', cost: 45, strength: 13, moves: 4, tech: 'sailing', obsolete: 'astronomy', upgrade: 'caravel', civ: 'carthage', replaces: 'trireme', abilities: ['coast_only'] }), + U({ id: 'winged_hussar', name: 'Winged Hussar', class: 'mounted', cost: 185, strength: 28, moves: 5, tech: 'metallurgy', obsolete: 'combined_arms', upgrade: 'anti_tank_gun', resource: 'horses', civ: 'poland', replaces: 'lancer', abilities: ['no_terrain_defense', 'penalty_vs_cities', 'withdraw'], promotions: ['shock_1', 'formation_1'] }), + U({ id: 'sea_beggar', name: 'Sea Beggar', class: 'naval_melee', domain: 'sea', cost: 150, strength: 25, moves: 5, tech: 'navigation', obsolete: 'combustion', upgrade: 'destroyer', civ: 'netherlands', replaces: 'privateer', abilities: ['heal_adjacent'], promotions: ['coastal_raider'] }), U({ id: 'maori_warrior', name: 'Maori Warrior', class: 'melee', cost: 40, strength: 8, moves: 2, obsolete: 'metal_casting', upgrade: 'swordsman', civ: 'polynesia', replaces: 'warrior', abilities: ['haka'] }), U({ id: 'berserker', name: 'Berserker', class: 'melee', cost: 120, strength: 21, moves: 3, tech: 'steel', obsolete: 'gunpowder', upgrade: 'musketman', resource: 'iron', civ: 'denmark', replaces: 'longswordsman', abilities: ['amphibious'] }), ]; diff --git a/src/games/blockilization/tests/cases.ts b/src/games/blockilization/tests/cases.ts index b5ebe0b..544ce25 100644 --- a/src/games/blockilization/tests/cases.ts +++ b/src/games/blockilization/tests/cases.ts @@ -31,13 +31,13 @@ import { bestSite, siteValue } from '../rules/ai/settle'; import { canEnter, occupants, planPath, stepCost } from '../rules/movement'; import { adoptable, ALL_POLICIES } from '../rules/policies'; import { previewAttack, previewCityFire } from '../rules/preview'; -import { promotionsDue, promotionsFor } from '../rules/promotions'; +import { promotionsDue, promotionsFor, promotionsOf } from '../rules/promotions'; import { BELIEF_LIST } from '../rules/religion'; import { campCleared, QUEST_REWARD, questsFor, questsOf, questsTurn, type Quest, type QuestKind } from '../rules/quests'; import { allyOf, cannotBully, cannotCoup, cityStateBonus, cityStateTurn, coupChance, influenceFall, TRIBUTE_COST, tributeGold, tributeTurns } from '../rules/citystates'; import { banned, cannotPropose, delegates, delegateSources, embargoed, inForce, propose as proposeResolution, proposers, sessionTurns, vote as castVote } from '../rules/congress'; import { replayAt, replayEnd, type ReplayEvent } from '../rules/replay'; -import { MAP_SIZES, SPEEDS, TURN_LIMITS, type Game, type MapSize, type Settings } from '../rules/state'; +import { MAP_SIZES, SPEEDS, TURN_LIMITS, type City, type Game, type MapSize, type Settings, type Unit } from '../rules/state'; import { lonLatOf, TRUE_STARTS, tileOfLonLat } from '../rules/earth'; import { cityTourism, longGameTurn, maxRoutes, routesGold, routesOf, routeYield, spiesOf, spyCount, startRoute, surveillanceTurns, theft, THEFT_COST, unVote } from '../rules/trade'; import { TECHS } from '../rules/techs'; @@ -69,13 +69,13 @@ import { openProject, startProject, type WorldProject } from '../rules/projects' import { expectedTier, leavesProject, projectPlan, worldProject } from '../rules/ai/projects'; import { bestNukeStrike, nukeProduction, nukes as fireNukes, nukesWanted, strikeWorth } from '../rules/ai/nukes'; import { NUKE_INFLUENCE } from '../rules/diplomacy'; -import { bombInterceptChance } from '../rules/combat'; +import { bombInterceptChance, type Modifier } from '../rules/combat'; import { bestUnit, ERA_STARTS, SCENARIOS, startEraOf, startWords, techsBefore, turnLimitFor, withScenario } from '../rules/starts'; import { TECH_LIST } from '../rules/techs'; import { TURN_CHOICES } from '../rules/state'; import { autoPromote, eraOf, foundCity } from '../rules/game'; import { botAnswer, EMOTES, isEmote, isPing, moodOf, toneOf } from '../rules/chat'; -import { isMilitary } from '../rules/units'; +import { isMilitary, isRanged } from '../rules/units'; import { civEffects, has } from '../rules/core'; import { bribeTurns, clanCost, clansOf, clansTurn, clanUnit, hireRest, inciteTurns, newClan, raidTarget, SETTLE, spares } from '../rules/clans'; import { CLAN_STATES, SEVEN_CITIES_GOLD, speed } from '../rules/core'; @@ -807,6 +807,8 @@ test('a siege unit sets up as it fires when it has the moves for both (Civ V’s war(game, 0, 1); const t = unit(game, 'trebuchet', 0, at(10, 8)); const d = unit(game, 'warrior', 1, at(12, 8)); + // (A siege unit sees only a tile round it, Civ V's: another unit spots for it.) + unit(game, 'warrior', 0, at(11, 7)); game.players[0].techs.push('physics'); t.moves = 1; ok(!apply(game, 0, { t: 'attack', unit: t.id, target: d.tile }).ok, 'one move: no'); @@ -1170,7 +1172,7 @@ test('a long march stops when enemies come in sight', () => { // (Each with a city, to stay in the game turn after turn.) city(game, 0, at(1, 1)); city(game, 1, at(24, 16)); - const t = unit(game, 'trebuchet', 0, at(4, 8)); + const t = unit(game, 'spearman', 0, at(4, 8)); ok(apply(game, 0, { t: 'move', unit: t.id, to: at(16, 8) }).ok, 'off it goes'); const where = t.tile; unit(game, 'pikeman', 1, at(where % game.grid.width + 2, 8)); @@ -7047,7 +7049,7 @@ test('clans: a bot bribes a clan by its cities, pays one near its enemy to raid ok(tries(() => clan.hired !== undefined), 'a warrior hired'); }); -test('formations: two of a kind side by side join as a corps, and a third as an army: far stronger, costing what their units did', () => { +test('formations: two of a kind side by side join as a corps, and a third as an army: far stronger, at Civ VI’s upkeep, holding their units’ resources', () => { const { game, at } = blank(['egypt', 'babylon']); const egypt = game.players[0]; egypt.bot = false; @@ -7075,7 +7077,14 @@ test('formations: two of a kind side by side join as a corps, and a third as an ok(a.xp === 30 && a.promotions.includes('shock_1'), 'the more experienced one’s experience and promotions'); eq(a.moves, 0, 'its turn spent'); near(strength(game, { ...a, hp: 100, promotions: [] }, 'defend', null, a.tile) / lone, 1 + CORPS_BONUS / 100, 'a corps’s strength'); - eq(goldUpkeep(game, egypt).units, upkeep, 'upkeep as two units'); + // (Its bonus multiplies the rest, so it isn't watered down fortified on a hill: Civ VI's flat +10.) + const hill = { ...game.tiles[a.tile] }; + Object.assign(game.tiles[a.tile], { elevation: 'hills', feature: undefined }); + const dug = { stance: 'fortify' as const, fortified: 2 }; + near(strength(game, { ...a, hp: 100, promotions: [], ...dug }, 'defend', null, a.tile) / strength(game, { ...a, hp: 100, promotions: [], size: undefined, ...dug }, 'defend', null, a.tile), 1 + CORPS_BONUS / 100, 'as strong again fortified on a hill'); + game.tiles[a.tile] = hill; + // (Eight units' worth, three free, before; seven and a half after.) + eq(goldUpkeep(game, egypt).units, upkeep - 1, 'upkeep a unit and a half’s'); ok(egypt.notes.some((n) => n.text.includes('A Rifleman Corps has formed')), 'said so'); // An army: with Replaceable Parts, a corps and a third. const c = unit(game, 'rifleman', 0, at(7, 8)); @@ -7107,11 +7116,12 @@ test('formations: one that can’t be paid for loses a unit, not the lot', () => const egypt = game.players[0]; egypt.bot = false; city(game, 0, at(20, 4)); - for (let i = 0; i < 4; i++) unit(game, 'rifleman', 0, at(3 + i * 2, 4), { size: 3 }); + // (Each army's upkeep two units', Civ VI's.) + for (let i = 0; i < 6; i++) unit(game, 'rifleman', 0, at(3 + i * 2, 4), { size: 3 }); egypt.gold = 0; endTurn(game); - eq(game.units.filter((u) => u.owner === 0).reduce((s, u) => s + (u.size ?? 1), 0), 11, 'a unit of one army gone'); - eq(game.units.filter((u) => u.owner === 0).length, 4, 'every army still there'); + eq(game.units.filter((u) => u.owner === 0).reduce((s, u) => s + (u.size ?? 1), 0), 17, 'a unit of one army gone'); + eq(game.units.filter((u) => u.owner === 0).length, 6, 'every army still there'); ok(egypt.notes.some((n) => n.text.includes('a unit of your Rifleman Army has disbanded')), 'said so'); }); @@ -7133,3 +7143,43 @@ test('formations: a bot forms corps once it can, out of the enemy’s reach, nev ok(game.units.includes(garrison) && game.units.includes(by) && !garrison.size && !by.size, 'not the garrison'); ok(game.units.includes(c) && game.units.includes(d) && !c.size && !d.size, 'not those in the enemy’s reach'); }); + +test('combat bonuses, Civ V’s: spears +50% against horses, anti-tank +100% against armour, siege +200% against cities and no ground to hide in, armour unhindered at walls and moving on after it attacks', () => { + const { game, at } = blank(['egypt', 'babylon']); + war(game, 0, 1); + const mod = (u: Unit, role: 'attack' | 'defend' | 'ranged', against: Unit | City, label: string) => { + const why: Modifier[] = []; + strength(game, u, role, against, u.tile, why); + return why.find((m) => m.label === label)?.percent ?? 0; + }; + const spear = unit(game, 'spearman', 0, at(8, 8)); + const horse = unit(game, 'horseman', 1, at(9, 8)); + eq(mod(spear, 'defend', horse, 'Against mounted'), 50, 'spearmen against horses'); + const lancer = unit(game, 'lancer', 0, at(8, 10)); + ok(promotionsOf(lancer).includes('formation_1') && mod(lancer, 'attack', horse, 'Formation I') === 33, 'a lancer’s Formation I instead'); + const gun = unit(game, 'anti_tank_gun', 0, at(4, 4)); + const tank = unit(game, 'tank', 1, at(5, 4)); + eq(mod(gun, 'defend', tank, 'Against armour'), 100, 'anti-tank guns against armour'); + const walls = city(game, 1, at(14, 8)); + const catapult = unit(game, 'catapult', 0, at(12, 8)); + eq(mod(catapult, 'ranged', walls, 'Against cities'), 200, 'siege against cities'); + eq(mod(tank, 'attack', walls, 'Against cities'), 0, 'armour at walls as anywhere'); + eq(mod(horse, 'attack', walls, 'Against cities'), -33, 'horses still a third weaker'); + Object.assign(game.tiles[catapult.tile], { elevation: 'hills', feature: undefined }); + eq(mod(catapult, 'defend', spear, 'Hills'), 0, 'no hills for a catapult to hide in'); + eq(unitSight(game, catapult), 1, 'and a tile of sight'); + ok(isRanged(UNITS.bazooka) && UNITS.bazooka.range === 1, 'a bazooka fires'); + // Armour moves on after it attacks, as horses do. + const raider = unit(game, 'tank', 0, at(20, 14)); + unit(game, 'warrior', 1, at(21, 14)); + ok(apply(game, 0, { t: 'attack', unit: raider.id, target: at(21, 14) }).ok, 'the tank attacks'); + ok(raider.moves > 0, 'and has moves left'); + // A unique unit's own promotion: given, not earned. + const samurai = unit(game, 'samurai', 0, at(2, 14), { xp: 10 }); + ok(promotionsOf(samurai).includes('shock_1') && !samurai.promotions.includes('shock_1'), 'a samurai is born with Shock I'); + eq(promotionsDue(samurai), 1, 'which doesn’t use up its experience'); + ok(!promotionsFor(samurai).some((x) => x.id === 'shock_1'), 'nor is offered again'); + // A Nuclear Missile ties up two Uranium. + unit(game, 'nuclear_missile', 0, at(2, 2)); + eq(strategic(game, game.players[0]).uranium?.used, 2, 'two Uranium'); +}); diff --git a/src/games/blockilization/views.ts b/src/games/blockilization/views.ts index 6619df6..d54f697 100644 --- a/src/games/blockilization/views.ts +++ b/src/games/blockilization/views.ts @@ -19,7 +19,7 @@ import { questsFor } from './rules/quests'; import { delegates, delegateSources, embargoed, expectedVote, proposers, RESOLUTION_KINDS, resolutionInfo, RESOLUTIONS } from './rules/congress'; import { cannotHold, isProject, openProject, PROJECTS, projectsFor, projectTotal, tiersOf } from './rules/projects'; import { cityReligion, conversionNeed, faithCost, pressureOn, prophetCost, religionsLeft } from './rules/faith'; -import { promotionsDue, promotionsFor } from './rules/promotions'; +import { promotionsDue, promotionsFor, promotionsOf } from './rules/promotions'; import { takenBeliefs } from './rules/religion'; import { cityById, knowsOf, maxMoves, newPlayer } from './rules/core'; import { electionTurns, influence, maxRoutes, partsBuildable, routeHome, routeWay, routeYield, routesGold, routesOf, spiesOf, theft, tourismOf, tradeReach, unVote } from './rules/trade'; @@ -367,7 +367,7 @@ export function viewFor(game: Game, p: Player | null, timer: number | null, pres w.skip = u.skip; w.auto = u.auto; if (u.work) w.work = { what: u.work.what, ...workLeft(game, u)! }; - w.promotions = u.promotions; + w.promotions = promotionsOf(u); w.promotionsDue = promotionsDue(u); if (w.promotionsDue > 0) w.promotionOptions = promotionsFor(u).map((x) => x.id); w.religion = u.religion; From 489bf2a6d3d273313ceb85d1b62d0b2824fe30af Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:05:50 +0000 Subject: [PATCH 2/4] Blockilization: maps at Civ V's land per civ Duel 32x22, Small 46x32, Standard 56x36, Large 70x44, Huge 88x56: each gives a civ about the land it has on Civ V's map for as many players. Natural wonders by Civ V's count for that map (2 to 6). Replays record tiles in three digits on the biggest maps, so Huge keeps its replay. An oasis a river runs through is flood plains (Civ V keeps oases off fresh water); the bigger maps had one, its pool drowning its middle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJp3oDGF1p3b1w297oSHYf --- src/games/blockilization/rules/mapgen.ts | 11 ++++++----- src/games/blockilization/rules/replay.ts | 21 +++++++++++--------- src/games/blockilization/rules/state.ts | 25 +++++++++++++++++------- src/games/blockilization/tests/cases.ts | 13 +++++++----- 4 files changed, 44 insertions(+), 26 deletions(-) diff --git a/src/games/blockilization/rules/mapgen.ts b/src/games/blockilization/rules/mapgen.ts index a4419c7..97102a8 100644 --- a/src/games/blockilization/rules/mapgen.ts +++ b/src/games/blockilization/rules/mapgen.ts @@ -1,7 +1,7 @@ import { earthDry, earthSeas, earthLand, earthLatitude, earthRidges, earthRivers, earthWet, TRUE_STARTS, tileOfLonLat } from './earth'; import { distance, neighbour, neighbours, rowOf, colOf, within, type Grid } from './hex'; import { fitsSite, NATURAL_WONDER_LIST, type NaturalWonder } from './nature'; -import type { MapType, Tile } from './state'; +import { MAP_SIZES, sizeOfMap, type MapType, type Tile } from './state'; import { FEATURES, RESOURCES, TERRAINS, type FeatureId, type TerrainId } from './terrain'; /** @@ -254,8 +254,9 @@ export function generate(g: Grid, seed: number, type: MapType, players: number, at = next; } } - // Flood plains: desert on a river. - for (const tile of tiles) if (tile.river && tile.terrain === 'desert' && tile.elevation === 'flat' && !tile.feature) tile.feature = 'floodplains'; + // Flood plains: desert on a river. (An oasis is never on fresh water, as in Civ V: one a river runs + // through is flood plains.) + for (const tile of tiles) if (tile.river && tile.terrain === 'desert' && tile.elevation === 'flat' && (!tile.feature || tile.feature === 'oasis')) tile.feature = 'floodplains'; // --- Heights for the voxel map: hills a step up, mountains tall, land rising inland. for (let t = 0; t < n; t++) { @@ -403,14 +404,14 @@ function landmass(g: Grid, tiles: Tile[], from: number): Set { } /** - * Natural wonders: a few for the map's size (two on a duel map, six on a large one), each of a kind + * Natural wonders: a few for the map's size (Civ V's: two on a duel map, six on a huge one), each of a kind * picked at random where its site fits (`nature.ts`; a pair of tiles for some), spread apart, kept * off the starts (four tiles from a civ's, two from a city-state's) and never closing a way through * the land. Its tiles become its own: nothing else on them, and mountains to the rules unless it can * be entered. */ function placeNaturalWonders(g: Grid, tiles: Tile[], rng: Rng, starts: number[], players: number) { - const want = Math.max(1, Math.round((g.width * g.height) / 250)); + const want = MAP_SIZES[sizeOfMap(g)].wonders; const kinds = [...NATURAL_WONDER_LIST]; for (let i = kinds.length - 1; i > 0; i--) { const j = Math.floor(rng.next() * (i + 1)); diff --git a/src/games/blockilization/rules/replay.ts b/src/games/blockilization/rules/replay.ts index 0de8942..a74755b 100644 --- a/src/games/blockilization/rules/replay.ts +++ b/src/games/blockilization/rules/replay.ts @@ -7,7 +7,7 @@ import type { Game, Player } from './state'; * lost, wars and peace, wonders built, eras reached, civs fallen, the victory. It's recorded by * comparing the game with how it stood at the last record, once a turn (and when a city taken ends * the game), so the rules need no hooks of their own; and it's kept small, for the save: a border - * change is three characters, an event a short array. + * change is three characters (four on the largest maps), an event a short array. */ /** What happened, when: a city founded (1: a civ's capital, not a city-state's), taken, lost; a war, a peace (between two civs standing); a wonder built; an era reached; a civ fallen; the victory. */ @@ -23,7 +23,7 @@ export type ReplayEvent = | [turn: number, kind: 'won', winner: number, how: string]; export interface Replay { - /** Each turn's border changes, by turn: per tile whose owner changed, the tile (two digits) and its owner (one; `.`: nobody). */ + /** Each turn's border changes, by turn: per tile whose owner changed, the tile (two digits, three on a map of more than 4,096 tiles: `tileDigits`) and its owner (one; `.`: nobody). */ borders: string[]; events: ReplayEvent[]; } @@ -31,8 +31,8 @@ export interface Replay { /** The digits border changes are written in: 64 of them, none that JSON escapes. */ const DIGITS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'; const NOBODY = '.'; -/** Tiles two digits can name (the largest map has 1,500). */ -const MOST_TILES = DIGITS.length ** 2; +/** The digits a tile is written in: two, or three on a map too big for two to name every tile (a huge map has 4,928, a huge Earth 5,900). */ +const tileDigits = (tiles: number) => (tiles > DIGITS.length ** 2 ? 3 : 2); /** How things stand at a point of the replay: each tile's owner, the cities (by id), the wars (`a,b`, a < b), each civ's era, who has fallen, the wonders built. */ export interface ReplayState { @@ -54,9 +54,11 @@ function emptyState(tiles: number): ReplayState { /** A turn's border changes applied to the owners. */ function applyBorders(owners: Int16Array, packed: string) { if (!packed) return; - for (let i = 0; i + 2 < packed.length; i += 3) { - const t = DIGITS.indexOf(packed[i]) * DIGITS.length + DIGITS.indexOf(packed[i + 1]); - owners[t] = packed[i + 2] === NOBODY ? -1 : DIGITS.indexOf(packed[i + 2]); + const n = tileDigits(owners.length); + for (let i = 0; i + n < packed.length; i += n + 1) { + let t = 0; + for (let k = 0; k < n; k++) t = t * DIGITS.length + DIGITS.indexOf(packed[i + k]); + owners[t] = packed[i + n] === NOBODY ? -1 : DIGITS.indexOf(packed[i + n]); } } @@ -147,7 +149,6 @@ const major = (p: Player) => !p.cityState && p.civ !== 'barbarian'; * victory. `eraOf` is the rules' own (`game.ts`). */ export function recordReplay(game: Game, turn: number, eraOf: (p: Player) => number) { - if (game.tiles.length > MOST_TILES) return; const r = (game.replay ??= { borders: [], events: [] }); let s = lastSeen.get(game); if (!s) { @@ -160,11 +161,13 @@ export function recordReplay(game: Game, turn: number, eraOf: (p: Player) => num }; // Borders. let packed = ''; + const n = tileDigits(game.tiles.length); for (let t = 0; t < game.tiles.length; t++) { const owner = game.tiles[t].owner; if (owner === s.owners[t] || owner >= DIGITS.length) continue; s.owners[t] = owner; - packed += DIGITS[Math.floor(t / DIGITS.length)] + DIGITS[t % DIGITS.length] + (owner < 0 ? NOBODY : DIGITS[owner]); + for (let k = n - 1; k >= 0; k--) packed += DIGITS[Math.floor(t / DIGITS.length ** k) % DIGITS.length]; + packed += owner < 0 ? NOBODY : DIGITS[owner]; } while (r.borders.length <= turn) r.borders.push(''); r.borders[turn] += packed; diff --git a/src/games/blockilization/rules/state.ts b/src/games/blockilization/rules/state.ts index 7ff395a..a8a8b1e 100644 --- a/src/games/blockilization/rules/state.ts +++ b/src/games/blockilization/rules/state.ts @@ -441,15 +441,26 @@ export const MAP_TYPE_NAMES: Record = { continents: 'Continents /** A map type in a sentence ("a small continents map", "a small Earth map"). */ export const mapTypeWord = (t: MapType) => (t === 'earth' ? 'Earth' : MAP_TYPE_NAMES[t].toLowerCase()); -/** Map sizes: tiles across and down, and players and city-states they suit. */ -export const MAP_SIZES: Record = { - duel: { name: 'Duel', width: 26, height: 18, players: 2, cityStates: 2 }, - small: { name: 'Small', width: 34, height: 22, players: 4, cityStates: 4 }, - standard: { name: 'Standard', width: 42, height: 26, players: 6, cityStates: 6 }, - large: { name: 'Large', width: 50, height: 30, players: 8, cityStates: 8 }, - huge: { name: 'Huge', width: 60, height: 36, players: 10, cityStates: 10 }, +/** + * Map sizes: tiles across and down, and players and city-states they suit. Each gives a civ about + * the land it has on Civ V's map for as many players (Duel's two, Tiny's four, Small's six, + * Standard's eight, Large's ten): with a little more of the map land than Civ V makes, a little + * smaller across. And that map's natural wonders (Civ V's count for it). + */ +export const MAP_SIZES: Record = { + duel: { name: 'Duel', width: 32, height: 22, players: 2, cityStates: 2, wonders: 2 }, + small: { name: 'Small', width: 46, height: 32, players: 4, cityStates: 4, wonders: 3 }, + standard: { name: 'Standard', width: 56, height: 36, players: 6, cityStates: 6, wonders: 4 }, + large: { name: 'Large', width: 70, height: 44, players: 8, cityStates: 8, wonders: 5 }, + huge: { name: 'Huge', width: 88, height: 56, players: 10, cityStates: 10, wonders: 6 }, }; +/** The size a map of so many tiles is: the largest it's as big as (an Earth map, a fifth bigger, its own; anything smaller a duel). */ +export function sizeOfMap(g: { width: number; height: number }): MapSize { + const sizes = Object.keys(MAP_SIZES) as MapSize[]; + return sizes.filter((k) => MAP_SIZES[k].width * MAP_SIZES[k].height <= g.width * g.height).pop() ?? 'duel'; +} + /** * The Earth map's tiles across and down at a size: a fifth more than the size's other maps have (the * Earth is mostly sea: this gives about as much land), in the Earth's own proportions (360° of diff --git a/src/games/blockilization/tests/cases.ts b/src/games/blockilization/tests/cases.ts index 544ce25..7fda7ba 100644 --- a/src/games/blockilization/tests/cases.ts +++ b/src/games/blockilization/tests/cases.ts @@ -1210,6 +1210,8 @@ test('a long march stops when enemies come in sight', () => { test('meeting goes both ways, and lasts', () => { const { game, at } = blank(['egypt', 'babylon']); const [a, b] = game.players; + // (Nothing seen yet of the map it began with.) + for (const p of game.players) p.explored.fill(0); const scout = unit(game, 'scout', 0, at(10, 8)); city(game, 1, at(20, 14)); ok(!hasMet(game, a, b) && !hasMet(game, b, a), 'strangers at first'); @@ -3294,7 +3296,7 @@ test('map generation: an archipelago is many islands, the civs mostly each on th test('map generation: natural wonders by the map’s size, the same for a seed, off the starts, spread apart, each its tiles’ own', () => { for (const wrap of [false, true]) - for (const [size, want] of [['duel', 2], ['small', 3], ['standard', 4], ['large', 6]] as [MapSize, number][]) { + for (const [size, want] of [['duel', 2], ['small', 3], ['standard', 4], ['large', 5]] as [MapSize, number][]) { const m = MAP_SIZES[size]; const g = { width: m.width, height: m.height, wrap }; for (const seed of [3, 11]) { @@ -3389,7 +3391,7 @@ test('a world that wraps: new games wrap unless the lobby says not; the wrap is const flat = createGame({ ...settings, victories: { ...settings.victories }, wrap: false }, seats, 5); ok(!('wrap' in flat.grid), 'a flat one is as a game always was'); // The same seed, flat, makes the same world as it always did (one saved before the wrap is the same). - const before = generate({ width: 26, height: 18 }, 5, 'continents', 2, 0); + const before = generate({ width: MAP_SIZES.duel.width, height: MAP_SIZES.duel.height }, 5, 'continents', 2, 0); eq(flat.tiles.map((t) => t.terrain).join(), before.tiles.map((t) => t.terrain).join(), 'a flat world from a seed as before'); }); @@ -3493,7 +3495,7 @@ test('a huge map: ten civs and ten city-states, each with a start', () => { const civs = CIV_LIST.slice(0, 10).map((c) => c.id); const settings = { ...blank().game.settings, size: 'huge' as MapSize, type: 'continents' as const, cityStates: 10, wrap: true }; const game = createGame(settings, civs.map((civ) => ({ user: null, name: civ, civ, bot: true })), 3); - eq(game.grid.width * game.grid.height, 60 * 36, 'its size'); + eq(game.grid.width * game.grid.height, 88 * 56, 'its size'); eq(game.players.filter((p) => p.alive && !p.cityState).length, 10, 'every civ in it'); eq(game.players.filter((p) => p.alive && p.cityState).length, 10, 'every city-state in it'); }); @@ -4105,7 +4107,7 @@ test('a citadel takes the tiles round it, another civ’s too (not a city’s), test('trade routes need a way: a cargo ship only between cities on the same water, a caravan only over land', () => { const { game, at } = blank(); - for (let col = 0; col < 26; col++) game.tiles[at(col, 8)].terrain = 'coast'; + for (let col = 0; col < game.grid.width; col++) game.tiles[at(col, 8)].terrain = 'coast'; const p = game.players[0]; p.techs.push('sailing', 'animal_husbandry'); const port = city(game, 0, at(4, 7)); @@ -4315,7 +4317,8 @@ test('rivers run as a tree, not a web, round their tiles’ middles: none of a r // (Joined every way, they were 42% water on average, 58% where they're dense, and some 80%.) ok(mean(shares) < 0.33, `a river tile is under a third water, on average (${(mean(shares) * 100).toFixed(0)}%)`); ok(mean(dense) < 0.4, `where rivers are dense, well under half (${(mean(dense) * 100).toFixed(0)}%, of ${dense.length})`); - ok(Math.max(...shares) <= 0.62, `none is mostly water (${(Math.max(...shares) * 100).toFixed(0)}% at most)`); + // (The worst a fork three ways round, each a third of the tile apart: 63% on the bigger maps.) + ok(Math.max(...shares) <= 0.65, `none is mostly water (${(Math.max(...shares) * 100).toFixed(0)}% at most)`); }); test('wonders open as Civ V’s Brave New World has it: the Pyramids with Liberty, the Kremlin with Order, and so on, not with the tech alone', () => { From 818d1cfe17c6b544461e10ea0252ad2c1d98e1ad Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:34:21 +0000 Subject: [PATCH 3/4] Blockilization: resources placed as Civ V places them Strategic deposits come in Civ V's sizes (large Iron 6, Horses 4, Coal 7, Oil 7, Aluminum 8, Uranium 4; small ones 2 or 3) and by Civ V's lists of grounds and spacing, with sea Oil, a minor deposit by each city-state and the old guarantee of each strategic near every start. Luxuries by region: each civ's region gets a type of its own, placed around its start and through the region; each start a second type close by; city-states three types of their own; the rest scattered, and some types left off each map (11 on Duel, 2 on Huge). Marble as Civ V's. Yields to Civ V's: Wheat, Iron, Gold, Silver, Gems and Copper add nothing beyond their improvement; Sheep +1 food and Salt +1 food on it; Fish 1 food; Oil +3 production; Ivory, Furs and Truffles +1 gold; Citrus, Cocoa and Crab 1 food and 1 gold. Bison, a bonus on open plains and grassland: 1 food, a Camp for +1 production. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJp3oDGF1p3b1w297oSHYf --- src/games/blockilization/client/concepts.ts | 2 +- src/games/blockilization/client/icons.ts | 1 + src/games/blockilization/client/modelfiles.ts | 3 +- src/games/blockilization/client/pedia.ts | 6 +- src/games/blockilization/client/spots.ts | 1 + src/games/blockilization/modelids.ts | 4 +- src/games/blockilization/models/r_bison.glb | Bin 0 -> 13756 bytes src/games/blockilization/rules/mapgen.ts | 100 +--- src/games/blockilization/rules/resources.ts | 555 ++++++++++++++++++ src/games/blockilization/rules/terrain.ts | 61 +- src/games/blockilization/tests/cases.ts | 143 ++++- src/games/blockilization/tools/resources.mjs | 20 +- 12 files changed, 778 insertions(+), 118 deletions(-) create mode 100644 src/games/blockilization/models/r_bison.glb create mode 100644 src/games/blockilization/rules/resources.ts diff --git a/src/games/blockilization/client/concepts.ts b/src/games/blockilization/client/concepts.ts index 3b770e5..7c81be8 100644 --- a/src/games/blockilization/client/concepts.ts +++ b/src/games/blockilization/client/concepts.ts @@ -61,7 +61,7 @@ export const CONCEPTS: Article[] = [ sections: [ { text: 'Every tile yields something by its [[cat:terrain|terrain]] and feature: grassland food, hills production, the coast food and gold. Rivers add gold and fresh water. [[cat:resource|Resources]] add more, once improved.' }, { title: 'Workers', text: '[[unit:worker|Workers]] build [[cat:improvement|improvements]]: farms for food, mines for production, trading posts for gold, and the right improvement on each resource (pastures, camps, plantations, quarries, fishing boats from a Work Boat). They also build roads, which connect cities and speed units.' }, - { title: 'Resources', text: 'Bonus resources yield more food or production. Luxury resources make your empire [[concept:happiness|happy]], each kind once. Strategic resources (horses, iron, coal, oil, aluminum, uranium) are needed by the units and buildings that use them; each improved deposit supplies a few.' }, + { title: 'Resources', text: 'Bonus resources yield more food or production. Luxury resources make your empire [[concept:happiness|happy]], each kind once. They lie by region, as in Civ V: each civ’s land has plenty of one kind (one by its start), a second kind by its start, and the rest are found elsewhere, by the city-states or traded for. Strategic resources (horses, iron, coal, oil, aluminum, uranium) are needed by the units and buildings that use them; each improved deposit supplies its amount, a small one 2 or 3, a large one 4 to 8.' }, { title: 'Fallout', text: 'A nuclear blast leaves [[terrain:fallout|fallout]] on much of the land it strikes. A tile under fallout yields next to nothing, hurts units that end their turn on it, and can’t be improved or repaired until a worker scrubs it away (Scrub Fallout).' }, ], }, diff --git a/src/games/blockilization/client/icons.ts b/src/games/blockilization/client/icons.ts index 56daf55..ba6f813 100644 --- a/src/games/blockilization/client/icons.ts +++ b/src/games/blockilization/client/icons.ts @@ -67,6 +67,7 @@ const G: Record = { cattle: '', sheep: '', deer: '', + bison: '', bananas: '', fish: '', stone: '', diff --git a/src/games/blockilization/client/modelfiles.ts b/src/games/blockilization/client/modelfiles.ts index 4f9025c..e93c13d 100644 --- a/src/games/blockilization/client/modelfiles.ts +++ b/src/games/blockilization/client/modelfiles.ts @@ -135,6 +135,7 @@ import r_sheep from '../models/r_sheep.glb?url'; import r_horses from '../models/r_horses.glb?url'; import r_horses2 from '../models/r_horses2.glb?url'; import r_deer from '../models/r_deer.glb?url'; +import r_bison from '../models/r_bison.glb?url'; import r_ivory from '../models/r_ivory.glb?url'; import r_furs from '../models/r_furs.glb?url'; import r_truffles from '../models/r_truffles.glb?url'; @@ -167,4 +168,4 @@ import r_marble from '../models/r_marble.glb?url'; import r_copper from '../models/r_copper.glb?url'; import r_salt from '../models/r_salt.glb?url'; -export const MODEL_FILES: Record = { u_warrior, u_spear, u_pike, u_sword, u_longsword, u_bow, u_crossbow, u_musket, u_rifle, u_infantry, u_mech, u_scout, u_worker, u_missionary, u_inquisitor, u_prophet, u_great, u_horse, u_knight, u_cavalry, u_elephant, u_camel, u_general, u_chariot, u_catapult, u_trebuchet, u_cannon, u_artillery, u_rocket, u_gatling, u_landship, u_tank, u_armor, u_robot, u_heli, u_triplane, u_oldbomber, u_fighter, u_bomber, u_stealth, u_missile, u_nuke, u_galley, u_turtle, u_sail, u_ironclad, u_warship, u_sub, u_carrier, u_cargo, u_boat, u_transport, u_settler, u_caravan, c_hut, c_house, c_stone, c_brick, c_block, c_tower, c_keep, c_palace, c_temple, c_wonder, c_factory, c_wall1, c_tower1, c_wall2, c_tower2, c_wall3, c_tower3, p_arrows, p_stone, p_shell, m_camp, m_ruin, m_bird, m_bird2, m_ship, w_pyramid, w_henge, w_colossus, w_liberty, w_cristo, w_zeus, w_lighthouse, w_gardens, w_temple, w_palace, w_wall, w_arena, w_dome, w_steps, w_cathedral, w_castle, w_pagoda, w_clock, w_spire, w_gate, w_theatre, w_opera, w_modern, w_dish, w_army, w_rocket, w_atom, w_mausoleum, w_oracle, w_parthenon, w_great_mosque, w_alhambra, w_taj_mahal, w_borobudur, w_angkor_wat, w_machu_picchu, w_great_library, w_forbidden_palace, w_uffizi, w_louvre, w_sistine_chapel, w_leaning_tower, w_himeji_castle, w_broadway, w_cn_tower, w_petra, w_united_nations, w_pentagon, w_great_firewall, w_kremlin, r_cattle, r_cattle2, r_sheep, r_horses, r_horses2, r_deer, r_ivory, r_furs, r_truffles, r_fish, r_whales, r_crab, r_sandbar, r_pearls, r_wheat, r_bananas, r_cotton, r_dyes, r_spices, r_silk, r_sugar, r_wine, r_incense, r_citrus, r_cocoa, r_stone, r_iron, r_coal, r_oil, r_aluminum, r_uranium, r_gold_ore, r_silver, r_gems, r_marble, r_copper, r_salt }; +export const MODEL_FILES: Record = { u_warrior, u_spear, u_pike, u_sword, u_longsword, u_bow, u_crossbow, u_musket, u_rifle, u_infantry, u_mech, u_scout, u_worker, u_missionary, u_inquisitor, u_prophet, u_great, u_horse, u_knight, u_cavalry, u_elephant, u_camel, u_general, u_chariot, u_catapult, u_trebuchet, u_cannon, u_artillery, u_rocket, u_gatling, u_landship, u_tank, u_armor, u_robot, u_heli, u_triplane, u_oldbomber, u_fighter, u_bomber, u_stealth, u_missile, u_nuke, u_galley, u_turtle, u_sail, u_ironclad, u_warship, u_sub, u_carrier, u_cargo, u_boat, u_transport, u_settler, u_caravan, c_hut, c_house, c_stone, c_brick, c_block, c_tower, c_keep, c_palace, c_temple, c_wonder, c_factory, c_wall1, c_tower1, c_wall2, c_tower2, c_wall3, c_tower3, p_arrows, p_stone, p_shell, m_camp, m_ruin, m_bird, m_bird2, m_ship, w_pyramid, w_henge, w_colossus, w_liberty, w_cristo, w_zeus, w_lighthouse, w_gardens, w_temple, w_palace, w_wall, w_arena, w_dome, w_steps, w_cathedral, w_castle, w_pagoda, w_clock, w_spire, w_gate, w_theatre, w_opera, w_modern, w_dish, w_army, w_rocket, w_atom, w_mausoleum, w_oracle, w_parthenon, w_great_mosque, w_alhambra, w_taj_mahal, w_borobudur, w_angkor_wat, w_machu_picchu, w_great_library, w_forbidden_palace, w_uffizi, w_louvre, w_sistine_chapel, w_leaning_tower, w_himeji_castle, w_broadway, w_cn_tower, w_petra, w_united_nations, w_pentagon, w_great_firewall, w_kremlin, r_cattle, r_cattle2, r_sheep, r_horses, r_horses2, r_deer, r_bison, r_ivory, r_furs, r_truffles, r_fish, r_whales, r_crab, r_sandbar, r_pearls, r_wheat, r_bananas, r_cotton, r_dyes, r_spices, r_silk, r_sugar, r_wine, r_incense, r_citrus, r_cocoa, r_stone, r_iron, r_coal, r_oil, r_aluminum, r_uranium, r_gold_ore, r_silver, r_gems, r_marble, r_copper, r_salt }; diff --git a/src/games/blockilization/client/pedia.ts b/src/games/blockilization/client/pedia.ts index 5a5cf21..2bb1426 100644 --- a/src/games/blockilization/client/pedia.ts +++ b/src/games/blockilization/client/pedia.ts @@ -10,7 +10,7 @@ import { BELIEF_LIST, BELIEFS } from '../rules/religion'; import { EUREKAS } from '../rules/eurekas'; import { SPEEDS } from '../rules/state'; import { baseTechCost, ERA_NAMES, ERAS, eraOfCol, TECH_LIST, TECHS, type Era, type Tech } from '../rules/techs'; -import { FALLOUT, FEATURES, HILLS, IMPROVEMENTS, MOUNTAIN, RESOURCES, TERRAINS, YIELD_KEYS, type FeatureId, type ImprovementId, type TerrainId, type Yields } from '../rules/terrain'; +import { addYields, FALLOUT, FEATURES, HILLS, IMPROVEMENTS, MOUNTAIN, RESOURCES, TERRAINS, YIELD_KEYS, type FeatureId, type ImprovementId, type TerrainId, type Yields } from '../rules/terrain'; import { UNIT_LIST, UNITS, type UnitType } from '../rules/units'; import { centre } from '../geometry'; import { lookAt } from './camera'; @@ -689,9 +689,9 @@ function resourcePage(id: string): Page { const r = RESOURCES[id]; const facts: Fact[] = [ ['food', YIELD_COLOR.food, 'Yields', yieldsEl(r.yields, true)], - ['hammer', '#f0a24a', 'Improved', yieldsEl(r.improved, true)], + ['hammer', '#f0a24a', 'Improved', yieldsEl(addYields(IMPROVEMENTS[r.improvement].yields, r.improved), true)], ]; - if (r.amount) facts.push(['hexes', r.color, 'Each deposit', `${r.amount[0]}–${r.amount[1]}`]); + if (r.amount) facts.push(['hexes', r.color, 'Each deposit', `${r.amount[0]} small, ${r.amount[1]} large`]); const owned = store.view?.self.strategic[id]; if (owned) facts.push(['check', '#6fd27a', 'You have', `${owned.have - owned.used} free of ${owned.have}`]); const kindText: Record = { diff --git a/src/games/blockilization/client/spots.ts b/src/games/blockilization/client/spots.ts index c649418..0aeb37c 100644 --- a/src/games/blockilization/client/spots.ts +++ b/src/games/blockilization/client/spots.ts @@ -40,6 +40,7 @@ export const KINDS: Partial> = { cattle: { models: ['r_cattle', 'r_cattle2', 'r_cattle'], count: 3, scale: 2.9, life: 'graze' }, sheep: { models: ['r_sheep'], count: 3, scale: 2.9, life: 'graze' }, deer: { models: ['r_deer'], count: 2, scale: 2.6, life: 'graze' }, + bison: { models: ['r_bison'], count: 2, scale: 2.8, life: 'graze' }, bananas: { models: ['r_bananas'], count: 2, scale: 3.4, life: 'still' }, fish: { models: ['r_fish'], count: 2, scale: 2.4, life: 'swim', sea: true, lift: 0.1 }, stone: { models: ['r_stone'], count: 2, scale: 3.1, life: 'still' }, diff --git a/src/games/blockilization/modelids.ts b/src/games/blockilization/modelids.ts index de00b7f..a387888 100644 --- a/src/games/blockilization/modelids.ts +++ b/src/games/blockilization/modelids.ts @@ -1,6 +1,6 @@ /** The voxel models (written by tools/models.mjs; don't edit). Each is an item, `civm_`, that only screens draw. */ -export const MODEL_IDS = ["u_warrior","u_spear","u_pike","u_sword","u_longsword","u_bow","u_crossbow","u_musket","u_rifle","u_infantry","u_mech","u_scout","u_worker","u_missionary","u_inquisitor","u_prophet","u_great","u_horse","u_knight","u_cavalry","u_elephant","u_camel","u_general","u_chariot","u_catapult","u_trebuchet","u_cannon","u_artillery","u_rocket","u_gatling","u_landship","u_tank","u_armor","u_robot","u_heli","u_triplane","u_oldbomber","u_fighter","u_bomber","u_stealth","u_missile","u_nuke","u_galley","u_turtle","u_sail","u_ironclad","u_warship","u_sub","u_carrier","u_cargo","u_boat","u_transport","u_settler","u_caravan","c_hut","c_house","c_stone","c_brick","c_block","c_tower","c_keep","c_palace","c_temple","c_wonder","c_factory","c_wall1","c_tower1","c_wall2","c_tower2","c_wall3","c_tower3","p_arrows","p_stone","p_shell","m_camp","m_ruin","m_bird","m_bird2","m_ship","w_pyramid","w_henge","w_colossus","w_liberty","w_cristo","w_zeus","w_lighthouse","w_gardens","w_temple","w_palace","w_wall","w_arena","w_dome","w_steps","w_cathedral","w_castle","w_pagoda","w_clock","w_spire","w_gate","w_theatre","w_opera","w_modern","w_dish","w_army","w_rocket","w_atom","w_mausoleum","w_oracle","w_parthenon","w_great_mosque","w_alhambra","w_taj_mahal","w_borobudur","w_angkor_wat","w_machu_picchu","w_great_library","w_forbidden_palace","w_uffizi","w_louvre","w_sistine_chapel","w_leaning_tower","w_himeji_castle","w_broadway","w_cn_tower","w_petra","w_united_nations","w_pentagon","w_great_firewall","w_kremlin","r_cattle","r_cattle2","r_sheep","r_horses","r_horses2","r_deer","r_ivory","r_furs","r_truffles","r_fish","r_whales","r_crab","r_sandbar","r_pearls","r_wheat","r_bananas","r_cotton","r_dyes","r_spices","r_silk","r_sugar","r_wine","r_incense","r_citrus","r_cocoa","r_stone","r_iron","r_coal","r_oil","r_aluminum","r_uranium","r_gold_ore","r_silver","r_gems","r_marble","r_copper","r_salt"] as const; +export const MODEL_IDS = ["u_warrior","u_spear","u_pike","u_sword","u_longsword","u_bow","u_crossbow","u_musket","u_rifle","u_infantry","u_mech","u_scout","u_worker","u_missionary","u_inquisitor","u_prophet","u_great","u_horse","u_knight","u_cavalry","u_elephant","u_camel","u_general","u_chariot","u_catapult","u_trebuchet","u_cannon","u_artillery","u_rocket","u_gatling","u_landship","u_tank","u_armor","u_robot","u_heli","u_triplane","u_oldbomber","u_fighter","u_bomber","u_stealth","u_missile","u_nuke","u_galley","u_turtle","u_sail","u_ironclad","u_warship","u_sub","u_carrier","u_cargo","u_boat","u_transport","u_settler","u_caravan","c_hut","c_house","c_stone","c_brick","c_block","c_tower","c_keep","c_palace","c_temple","c_wonder","c_factory","c_wall1","c_tower1","c_wall2","c_tower2","c_wall3","c_tower3","p_arrows","p_stone","p_shell","m_camp","m_ruin","m_bird","m_bird2","m_ship","w_pyramid","w_henge","w_colossus","w_liberty","w_cristo","w_zeus","w_lighthouse","w_gardens","w_temple","w_palace","w_wall","w_arena","w_dome","w_steps","w_cathedral","w_castle","w_pagoda","w_clock","w_spire","w_gate","w_theatre","w_opera","w_modern","w_dish","w_army","w_rocket","w_atom","w_mausoleum","w_oracle","w_parthenon","w_great_mosque","w_alhambra","w_taj_mahal","w_borobudur","w_angkor_wat","w_machu_picchu","w_great_library","w_forbidden_palace","w_uffizi","w_louvre","w_sistine_chapel","w_leaning_tower","w_himeji_castle","w_broadway","w_cn_tower","w_petra","w_united_nations","w_pentagon","w_great_firewall","w_kremlin","r_cattle","r_cattle2","r_sheep","r_horses","r_horses2","r_deer","r_bison","r_ivory","r_furs","r_truffles","r_fish","r_whales","r_crab","r_sandbar","r_pearls","r_wheat","r_bananas","r_cotton","r_dyes","r_spices","r_silk","r_sugar","r_wine","r_incense","r_citrus","r_cocoa","r_stone","r_iron","r_coal","r_oil","r_aluminum","r_uranium","r_gold_ore","r_silver","r_gems","r_marble","r_copper","r_salt"] as const; export type ModelId = (typeof MODEL_IDS)[number]; /** Each model's extent in voxels (a sixteenth of a block): [lo, hi) on x, y, z. */ -export const MODEL_SIZE: Record = {"u_warrior":{"lo":[-10,0,-8],"hi":[11,15,6]},"u_spear":{"lo":[-11,0,-8],"hi":[11,19,6]},"u_pike":{"lo":[-10,0,-8],"hi":[11,24,6]},"u_sword":{"lo":[-11,0,-8],"hi":[11,15,6]},"u_longsword":{"lo":[-10,0,-8],"hi":[12,16,6]},"u_bow":{"lo":[-12,0,-8],"hi":[10,15,6]},"u_crossbow":{"lo":[-10,0,-8],"hi":[10,15,10]},"u_musket":{"lo":[-10,0,-9],"hi":[11,16,7]},"u_rifle":{"lo":[-10,0,-8],"hi":[11,17,6]},"u_infantry":{"lo":[-10,0,-9],"hi":[11,16,7]},"u_mech":{"lo":[-10,0,-8],"hi":[11,16,6]},"u_scout":{"lo":[-4,0,-3],"hi":[5,17,3]},"u_worker":{"lo":[-9,0,-3],"hi":[11,17,3]},"u_missionary":{"lo":[-4,0,-2],"hi":[4,15,3]},"u_inquisitor":{"lo":[-4,0,-2],"hi":[4,15,3]},"u_prophet":{"lo":[-4,0,-2],"hi":[5,15,2]},"u_great":{"lo":[-4,0,-2],"hi":[4,15,3]},"u_horse":{"lo":[-11,0,-11],"hi":[12,24,11]},"u_knight":{"lo":[-12,0,-11],"hi":[12,29,11]},"u_cavalry":{"lo":[-11,0,-11],"hi":[12,22,11]},"u_elephant":{"lo":[-5,0,-8],"hi":[5,28,14]},"u_camel":{"lo":[-13,0,-11],"hi":[11,23,11]},"u_general":{"lo":[-4,0,-8],"hi":[11,27,11]},"u_chariot":{"lo":[-7,0,-6],"hi":[7,18,25]},"u_catapult":{"lo":[-14,0,-12],"hi":[7,15,8]},"u_trebuchet":{"lo":[-14,0,-14],"hi":[7,20,14]},"u_cannon":{"lo":[-14,0,-12],"hi":[14,16,15]},"u_artillery":{"lo":[-14,0,-12],"hi":[14,16,23]},"u_rocket":{"lo":[-8,0,-12],"hi":[8,14,11]},"u_gatling":{"lo":[-13,0,-9],"hi":[13,16,13]},"u_landship":{"lo":[-11,0,-14],"hi":[11,13,14]},"u_tank":{"lo":[-9,0,-14],"hi":[9,16,26]},"u_armor":{"lo":[-9,0,-14],"hi":[9,15,26]},"u_robot":{"lo":[-12,0,-5],"hi":[12,31,14]},"u_heli":{"lo":[-16,12,-22],"hi":[16,24,16]},"u_triplane":{"lo":[-14,17,-11],"hi":[14,26,13]},"u_oldbomber":{"lo":[-22,17,-16],"hi":[22,25,18]},"u_fighter":{"lo":[-14,18,-11],"hi":[14,25,12]},"u_bomber":{"lo":[-22,17,-16],"hi":[22,25,17]},"u_stealth":{"lo":[-28,18,-3],"hi":[28,22,11]},"u_missile":{"lo":[-5,18,-14],"hi":[5,25,15]},"u_nuke":{"lo":[-5,18,-14],"hi":[5,25,15]},"u_galley":{"lo":[-9,0,-18],"hi":[9,20,21]},"u_turtle":{"lo":[-6,0,-18],"hi":[6,8,21]},"u_sail":{"lo":[-8,0,-20],"hi":[9,28,25]},"u_ironclad":{"lo":[-6,0,-18],"hi":[6,13,18]},"u_warship":{"lo":[-6,0,-24],"hi":[6,18,24]},"u_sub":{"lo":[-4,0,-20],"hi":[4,9,20]},"u_carrier":{"lo":[-8,0,-24],"hi":[8,13,24]},"u_cargo":{"lo":[-6,0,-20],"hi":[6,13,20]},"u_boat":{"lo":[-5,0,-9],"hi":[7,20,9]},"u_transport":{"lo":[-11,0,-16],"hi":[11,16,16]},"u_settler":{"lo":[-14,0,-11],"hi":[6,17,26]},"u_caravan":{"lo":[-13,0,-18],"hi":[4,17,19]},"c_hut":{"lo":[-6,0,-6],"hi":[6,12,6]},"c_house":{"lo":[-6,0,-5],"hi":[6,12,5]},"c_stone":{"lo":[-6,0,-6],"hi":[6,14,6]},"c_brick":{"lo":[-6,0,-6],"hi":[6,23,6]},"c_block":{"lo":[-5,0,-5],"hi":[5,31,5]},"c_tower":{"lo":[-6,0,-6],"hi":[6,47,6]},"c_keep":{"lo":[-5,0,-5],"hi":[5,26,5]},"c_palace":{"lo":[-10,0,-8],"hi":[10,25,8]},"c_temple":{"lo":[-7,0,-9],"hi":[7,15,9]},"c_wonder":{"lo":[-10,0,-10],"hi":[10,24,10]},"c_factory":{"lo":[-8,0,-6],"hi":[8,25,6]},"c_wall1":{"lo":[-10,0,-1],"hi":[10,8,2]},"c_tower1":{"lo":[-4,0,-4],"hi":[4,11,4]},"c_wall2":{"lo":[-10,0,-2],"hi":[10,11,2]},"c_tower2":{"lo":[-5,0,-5],"hi":[5,16,5]},"c_wall3":{"lo":[-10,0,-2],"hi":[10,6,3]},"c_tower3":{"lo":[-4,0,-4],"hi":[4,8,4]},"p_arrows":{"lo":[-4,0,-9],"hi":[5,4,8]},"p_stone":{"lo":[-3,-2,-3],"hi":[3,3,3]},"p_shell":{"lo":[-2,-1,-2],"hi":[2,2,2]},"m_camp":{"lo":[-12,0,-11],"hi":[14,15,13]},"m_ruin":{"lo":[-8,0,-8],"hi":[8,12,8]},"m_bird":{"lo":[-4,-1,-2],"hi":[5,1,3]},"m_bird2":{"lo":[-4,0,-2],"hi":[5,3,3]},"m_ship":{"lo":[-6,-2,-6],"hi":[6,40,6]},"w_pyramid":{"lo":[-14,0,-14],"hi":[14,30,14]},"w_henge":{"lo":[-15,0,-15],"hi":[15,15,15]},"w_colossus":{"lo":[-8,0,-8],"hi":[8,41,8]},"w_liberty":{"lo":[-8,0,-8],"hi":[8,41,8]},"w_cristo":{"lo":[-14,0,-8],"hi":[14,32,8]},"w_zeus":{"lo":[-8,0,-8],"hi":[8,41,8]},"w_lighthouse":{"lo":[-8,0,-8],"hi":[8,37,8]},"w_gardens":{"lo":[-14,0,-14],"hi":[14,24,15]},"w_temple":{"lo":[-14,0,-9],"hi":[14,24,9]},"w_palace":{"lo":[-16,0,-10],"hi":[16,24,11]},"w_wall":{"lo":[-16,0,-16],"hi":[16,26,16]},"w_arena":{"lo":[-15,0,-11],"hi":[16,18,12]},"w_dome":{"lo":[-14,0,-14],"hi":[14,28,14]},"w_steps":{"lo":[-14,0,-14],"hi":[14,27,15]},"w_cathedral":{"lo":[-8,0,-14],"hi":[8,32,17]},"w_castle":{"lo":[-13,0,-13],"hi":[13,30,13]},"w_pagoda":{"lo":[-8,0,-8],"hi":[8,47,8]},"w_clock":{"lo":[-6,0,-6],"hi":[6,56,8]},"w_spire":{"lo":[-13,0,-13],"hi":[13,52,13]},"w_gate":{"lo":[-16,0,-4],"hi":[16,32,4]},"w_theatre":{"lo":[-13,0,-13],"hi":[14,24,14]},"w_opera":{"lo":[-18,0,-10],"hi":[16,27,10]},"w_modern":{"lo":[-14,0,-14],"hi":[14,50,14]},"w_dish":{"lo":[-16,0,-16],"hi":[16,28,16]},"w_army":{"lo":[-19,0,-12],"hi":[20,19,12]},"w_rocket":{"lo":[-12,0,-12],"hi":[12,46,12]},"w_atom":{"lo":[-13,0,-11],"hi":[14,31,12]},"w_mausoleum":{"lo":[-13,0,-13],"hi":[13,41,13]},"w_oracle":{"lo":[-15,0,-15],"hi":[15,23,15]},"w_parthenon":{"lo":[-16,0,-15],"hi":[16,26,15]},"w_great_mosque":{"lo":[-16,0,-13],"hi":[16,32,14]},"w_alhambra":{"lo":[-16,0,-14],"hi":[16,23,14]},"w_taj_mahal":{"lo":[-15,0,-15],"hi":[15,38,15]},"w_borobudur":{"lo":[-16,0,-16],"hi":[16,25,16]},"w_angkor_wat":{"lo":[-16,0,-16],"hi":[16,34,16]},"w_machu_picchu":{"lo":[-15,0,-15],"hi":[15,38,15]},"w_great_library":{"lo":[-16,0,-11],"hi":[16,27,15]},"w_forbidden_palace":{"lo":[-15,0,-12],"hi":[15,28,14]},"w_uffizi":{"lo":[-17,0,-17],"hi":[16,41,16]},"w_louvre":{"lo":[-16,0,-16],"hi":[16,17,16]},"w_sistine_chapel":{"lo":[-8,0,-15],"hi":[8,38,14]},"w_leaning_tower":{"lo":[-9,0,-9],"hi":[11,46,9]},"w_himeji_castle":{"lo":[-14,0,-10],"hi":[14,44,10]},"w_broadway":{"lo":[-15,0,-12],"hi":[16,38,13]},"w_cn_tower":{"lo":[-11,0,-11],"hi":[11,55,11]},"w_petra":{"lo":[-16,0,-6],"hi":[16,42,14]},"w_united_nations":{"lo":[-16,0,-12],"hi":[17,47,13]},"w_pentagon":{"lo":[-16,0,-16],"hi":[16,7,15]},"w_great_firewall":{"lo":[-16,0,-14],"hi":[17,31,14]},"w_kremlin":{"lo":[-16,0,-15],"hi":[16,37,15]},"r_cattle":{"lo":[-4,0,-6],"hi":[4,11,9]},"r_cattle2":{"lo":[-4,0,-6],"hi":[4,11,9]},"r_sheep":{"lo":[-4,0,-5],"hi":[4,10,7]},"r_horses":{"lo":[-2,0,-8],"hi":[2,16,12]},"r_horses2":{"lo":[-2,0,-8],"hi":[2,16,12]},"r_deer":{"lo":[-3,0,-6],"hi":[3,21,10]},"r_ivory":{"lo":[-7,0,-8],"hi":[7,16,15]},"r_furs":{"lo":[-2,0,-11],"hi":[2,10,10]},"r_truffles":{"lo":[-3,0,-6],"hi":[3,7,13]},"r_fish":{"lo":[-10,-2,-10],"hi":[11,2,11]},"r_whales":{"lo":[-7,-3,-18],"hi":[7,5,13]},"r_crab":{"lo":[-4,0,-1],"hi":[4,4,4]},"r_sandbar":{"lo":[-11,-3,-8],"hi":[11,1,8]},"r_pearls":{"lo":[-8,-3,-6],"hi":[8,5,6]},"r_wheat":{"lo":[-6,0,-6],"hi":[7,10,7]},"r_bananas":{"lo":[-9,0,-9],"hi":[9,15,9]},"r_cotton":{"lo":[-4,0,-4],"hi":[5,8,4]},"r_dyes":{"lo":[-4,0,-4],"hi":[4,9,4]},"r_spices":{"lo":[-4,0,-4],"hi":[4,5,4]},"r_silk":{"lo":[-5,0,-4],"hi":[5,12,5]},"r_sugar":{"lo":[-5,0,-4],"hi":[6,14,5]},"r_wine":{"lo":[-7,0,-1],"hi":[7,9,2]},"r_incense":{"lo":[-6,0,-6],"hi":[7,12,7]},"r_citrus":{"lo":[-4,0,-4],"hi":[4,10,4]},"r_cocoa":{"lo":[-4,0,-4],"hi":[4,14,4]},"r_stone":{"lo":[-7,0,-6],"hi":[8,5,6]},"r_iron":{"lo":[-5,0,-4],"hi":[7,5,5]},"r_coal":{"lo":[-7,0,-4],"hi":[6,4,5]},"r_oil":{"lo":[-8,0,-7],"hi":[8,2,7]},"r_aluminum":{"lo":[-7,0,-5],"hi":[6,7,5]},"r_uranium":{"lo":[-5,0,-4],"hi":[6,9,4]},"r_gold_ore":{"lo":[-5,0,-4],"hi":[7,6,5]},"r_silver":{"lo":[-5,0,-4],"hi":[7,5,5]},"r_gems":{"lo":[-5,0,-4],"hi":[7,8,4]},"r_marble":{"lo":[-6,0,-4],"hi":[5,7,6]},"r_copper":{"lo":[-5,0,-4],"hi":[7,5,5]},"r_salt":{"lo":[-8,0,-7],"hi":[8,6,7]}}; +export const MODEL_SIZE: Record = {"u_warrior":{"lo":[-10,0,-8],"hi":[11,15,6]},"u_spear":{"lo":[-11,0,-8],"hi":[11,19,6]},"u_pike":{"lo":[-10,0,-8],"hi":[11,24,6]},"u_sword":{"lo":[-11,0,-8],"hi":[11,15,6]},"u_longsword":{"lo":[-10,0,-8],"hi":[12,16,6]},"u_bow":{"lo":[-12,0,-8],"hi":[10,15,6]},"u_crossbow":{"lo":[-10,0,-8],"hi":[10,15,10]},"u_musket":{"lo":[-10,0,-9],"hi":[11,16,7]},"u_rifle":{"lo":[-10,0,-8],"hi":[11,17,6]},"u_infantry":{"lo":[-10,0,-9],"hi":[11,16,7]},"u_mech":{"lo":[-10,0,-8],"hi":[11,16,6]},"u_scout":{"lo":[-4,0,-3],"hi":[5,17,3]},"u_worker":{"lo":[-9,0,-3],"hi":[11,17,3]},"u_missionary":{"lo":[-4,0,-2],"hi":[4,15,3]},"u_inquisitor":{"lo":[-4,0,-2],"hi":[4,15,3]},"u_prophet":{"lo":[-4,0,-2],"hi":[5,15,2]},"u_great":{"lo":[-4,0,-2],"hi":[4,15,3]},"u_horse":{"lo":[-11,0,-11],"hi":[12,24,11]},"u_knight":{"lo":[-12,0,-11],"hi":[12,29,11]},"u_cavalry":{"lo":[-11,0,-11],"hi":[12,22,11]},"u_elephant":{"lo":[-5,0,-8],"hi":[5,28,14]},"u_camel":{"lo":[-13,0,-11],"hi":[11,23,11]},"u_general":{"lo":[-4,0,-8],"hi":[11,27,11]},"u_chariot":{"lo":[-7,0,-6],"hi":[7,18,25]},"u_catapult":{"lo":[-14,0,-12],"hi":[7,15,8]},"u_trebuchet":{"lo":[-14,0,-14],"hi":[7,20,14]},"u_cannon":{"lo":[-14,0,-12],"hi":[14,16,15]},"u_artillery":{"lo":[-14,0,-12],"hi":[14,16,23]},"u_rocket":{"lo":[-8,0,-12],"hi":[8,14,11]},"u_gatling":{"lo":[-13,0,-9],"hi":[13,16,13]},"u_landship":{"lo":[-11,0,-14],"hi":[11,13,14]},"u_tank":{"lo":[-9,0,-14],"hi":[9,16,26]},"u_armor":{"lo":[-9,0,-14],"hi":[9,15,26]},"u_robot":{"lo":[-12,0,-5],"hi":[12,31,14]},"u_heli":{"lo":[-16,12,-22],"hi":[16,24,16]},"u_triplane":{"lo":[-14,17,-11],"hi":[14,26,13]},"u_oldbomber":{"lo":[-22,17,-16],"hi":[22,25,18]},"u_fighter":{"lo":[-14,18,-11],"hi":[14,25,12]},"u_bomber":{"lo":[-22,17,-16],"hi":[22,25,17]},"u_stealth":{"lo":[-28,18,-3],"hi":[28,22,11]},"u_missile":{"lo":[-5,18,-14],"hi":[5,25,15]},"u_nuke":{"lo":[-5,18,-14],"hi":[5,25,15]},"u_galley":{"lo":[-9,0,-18],"hi":[9,20,21]},"u_turtle":{"lo":[-6,0,-18],"hi":[6,8,21]},"u_sail":{"lo":[-8,0,-20],"hi":[9,28,25]},"u_ironclad":{"lo":[-6,0,-18],"hi":[6,13,18]},"u_warship":{"lo":[-6,0,-24],"hi":[6,18,24]},"u_sub":{"lo":[-4,0,-20],"hi":[4,9,20]},"u_carrier":{"lo":[-8,0,-24],"hi":[8,13,24]},"u_cargo":{"lo":[-6,0,-20],"hi":[6,13,20]},"u_boat":{"lo":[-5,0,-9],"hi":[7,20,9]},"u_transport":{"lo":[-11,0,-16],"hi":[11,16,16]},"u_settler":{"lo":[-14,0,-11],"hi":[6,17,26]},"u_caravan":{"lo":[-13,0,-18],"hi":[4,17,19]},"c_hut":{"lo":[-6,0,-6],"hi":[6,12,6]},"c_house":{"lo":[-6,0,-5],"hi":[6,12,5]},"c_stone":{"lo":[-6,0,-6],"hi":[6,14,6]},"c_brick":{"lo":[-6,0,-6],"hi":[6,23,6]},"c_block":{"lo":[-5,0,-5],"hi":[5,31,5]},"c_tower":{"lo":[-6,0,-6],"hi":[6,47,6]},"c_keep":{"lo":[-5,0,-5],"hi":[5,26,5]},"c_palace":{"lo":[-10,0,-8],"hi":[10,25,8]},"c_temple":{"lo":[-7,0,-9],"hi":[7,15,9]},"c_wonder":{"lo":[-10,0,-10],"hi":[10,24,10]},"c_factory":{"lo":[-8,0,-6],"hi":[8,25,6]},"c_wall1":{"lo":[-10,0,-1],"hi":[10,8,2]},"c_tower1":{"lo":[-4,0,-4],"hi":[4,11,4]},"c_wall2":{"lo":[-10,0,-2],"hi":[10,11,2]},"c_tower2":{"lo":[-5,0,-5],"hi":[5,16,5]},"c_wall3":{"lo":[-10,0,-2],"hi":[10,6,3]},"c_tower3":{"lo":[-4,0,-4],"hi":[4,8,4]},"p_arrows":{"lo":[-4,0,-9],"hi":[5,4,8]},"p_stone":{"lo":[-3,-2,-3],"hi":[3,3,3]},"p_shell":{"lo":[-2,-1,-2],"hi":[2,2,2]},"m_camp":{"lo":[-12,0,-11],"hi":[14,15,13]},"m_ruin":{"lo":[-8,0,-8],"hi":[8,12,8]},"m_bird":{"lo":[-4,-1,-2],"hi":[5,1,3]},"m_bird2":{"lo":[-4,0,-2],"hi":[5,3,3]},"m_ship":{"lo":[-6,-2,-6],"hi":[6,40,6]},"w_pyramid":{"lo":[-14,0,-14],"hi":[14,30,14]},"w_henge":{"lo":[-15,0,-15],"hi":[15,15,15]},"w_colossus":{"lo":[-8,0,-8],"hi":[8,41,8]},"w_liberty":{"lo":[-8,0,-8],"hi":[8,41,8]},"w_cristo":{"lo":[-14,0,-8],"hi":[14,32,8]},"w_zeus":{"lo":[-8,0,-8],"hi":[8,41,8]},"w_lighthouse":{"lo":[-8,0,-8],"hi":[8,37,8]},"w_gardens":{"lo":[-14,0,-14],"hi":[14,24,15]},"w_temple":{"lo":[-14,0,-9],"hi":[14,24,9]},"w_palace":{"lo":[-16,0,-10],"hi":[16,24,11]},"w_wall":{"lo":[-16,0,-16],"hi":[16,26,16]},"w_arena":{"lo":[-15,0,-11],"hi":[16,18,12]},"w_dome":{"lo":[-14,0,-14],"hi":[14,28,14]},"w_steps":{"lo":[-14,0,-14],"hi":[14,27,15]},"w_cathedral":{"lo":[-8,0,-14],"hi":[8,32,17]},"w_castle":{"lo":[-13,0,-13],"hi":[13,30,13]},"w_pagoda":{"lo":[-8,0,-8],"hi":[8,47,8]},"w_clock":{"lo":[-6,0,-6],"hi":[6,56,8]},"w_spire":{"lo":[-13,0,-13],"hi":[13,52,13]},"w_gate":{"lo":[-16,0,-4],"hi":[16,32,4]},"w_theatre":{"lo":[-13,0,-13],"hi":[14,24,14]},"w_opera":{"lo":[-18,0,-10],"hi":[16,27,10]},"w_modern":{"lo":[-14,0,-14],"hi":[14,50,14]},"w_dish":{"lo":[-16,0,-16],"hi":[16,28,16]},"w_army":{"lo":[-19,0,-12],"hi":[20,19,12]},"w_rocket":{"lo":[-12,0,-12],"hi":[12,46,12]},"w_atom":{"lo":[-13,0,-11],"hi":[14,31,12]},"w_mausoleum":{"lo":[-13,0,-13],"hi":[13,41,13]},"w_oracle":{"lo":[-15,0,-15],"hi":[15,23,15]},"w_parthenon":{"lo":[-16,0,-15],"hi":[16,26,15]},"w_great_mosque":{"lo":[-16,0,-13],"hi":[16,32,14]},"w_alhambra":{"lo":[-16,0,-14],"hi":[16,23,14]},"w_taj_mahal":{"lo":[-15,0,-15],"hi":[15,38,15]},"w_borobudur":{"lo":[-16,0,-16],"hi":[16,25,16]},"w_angkor_wat":{"lo":[-16,0,-16],"hi":[16,34,16]},"w_machu_picchu":{"lo":[-15,0,-15],"hi":[15,38,15]},"w_great_library":{"lo":[-16,0,-11],"hi":[16,27,15]},"w_forbidden_palace":{"lo":[-15,0,-12],"hi":[15,28,14]},"w_uffizi":{"lo":[-17,0,-17],"hi":[16,41,16]},"w_louvre":{"lo":[-16,0,-16],"hi":[16,17,16]},"w_sistine_chapel":{"lo":[-8,0,-15],"hi":[8,38,14]},"w_leaning_tower":{"lo":[-9,0,-9],"hi":[11,46,9]},"w_himeji_castle":{"lo":[-14,0,-10],"hi":[14,44,10]},"w_broadway":{"lo":[-15,0,-12],"hi":[16,38,13]},"w_cn_tower":{"lo":[-11,0,-11],"hi":[11,55,11]},"w_petra":{"lo":[-16,0,-6],"hi":[16,42,14]},"w_united_nations":{"lo":[-16,0,-12],"hi":[17,47,13]},"w_pentagon":{"lo":[-16,0,-16],"hi":[16,7,15]},"w_great_firewall":{"lo":[-16,0,-14],"hi":[17,31,14]},"w_kremlin":{"lo":[-16,0,-15],"hi":[16,37,15]},"r_cattle":{"lo":[-4,0,-6],"hi":[4,11,9]},"r_cattle2":{"lo":[-4,0,-6],"hi":[4,11,9]},"r_sheep":{"lo":[-4,0,-5],"hi":[4,10,7]},"r_horses":{"lo":[-2,0,-8],"hi":[2,16,12]},"r_horses2":{"lo":[-2,0,-8],"hi":[2,16,12]},"r_deer":{"lo":[-3,0,-6],"hi":[3,21,10]},"r_bison":{"lo":[-3,0,-6],"hi":[3,12,9]},"r_ivory":{"lo":[-7,0,-8],"hi":[7,16,15]},"r_furs":{"lo":[-2,0,-11],"hi":[2,10,10]},"r_truffles":{"lo":[-3,0,-6],"hi":[3,7,13]},"r_fish":{"lo":[-10,-2,-10],"hi":[11,2,11]},"r_whales":{"lo":[-7,-3,-18],"hi":[7,5,13]},"r_crab":{"lo":[-4,0,-1],"hi":[4,4,4]},"r_sandbar":{"lo":[-11,-3,-8],"hi":[11,1,8]},"r_pearls":{"lo":[-8,-3,-6],"hi":[8,5,6]},"r_wheat":{"lo":[-6,0,-6],"hi":[7,10,7]},"r_bananas":{"lo":[-9,0,-9],"hi":[9,15,9]},"r_cotton":{"lo":[-4,0,-4],"hi":[5,8,4]},"r_dyes":{"lo":[-4,0,-4],"hi":[4,9,4]},"r_spices":{"lo":[-4,0,-4],"hi":[4,5,4]},"r_silk":{"lo":[-5,0,-4],"hi":[5,12,5]},"r_sugar":{"lo":[-5,0,-4],"hi":[6,14,5]},"r_wine":{"lo":[-7,0,-1],"hi":[7,9,2]},"r_incense":{"lo":[-6,0,-6],"hi":[7,12,7]},"r_citrus":{"lo":[-4,0,-4],"hi":[4,10,4]},"r_cocoa":{"lo":[-4,0,-4],"hi":[4,14,4]},"r_stone":{"lo":[-7,0,-6],"hi":[8,5,6]},"r_iron":{"lo":[-5,0,-4],"hi":[7,5,5]},"r_coal":{"lo":[-7,0,-4],"hi":[6,4,5]},"r_oil":{"lo":[-8,0,-7],"hi":[8,2,7]},"r_aluminum":{"lo":[-7,0,-5],"hi":[6,7,5]},"r_uranium":{"lo":[-5,0,-4],"hi":[6,9,4]},"r_gold_ore":{"lo":[-5,0,-4],"hi":[7,6,5]},"r_silver":{"lo":[-5,0,-4],"hi":[7,5,5]},"r_gems":{"lo":[-5,0,-4],"hi":[7,8,4]},"r_marble":{"lo":[-6,0,-4],"hi":[5,7,6]},"r_copper":{"lo":[-5,0,-4],"hi":[7,5,5]},"r_salt":{"lo":[-8,0,-7],"hi":[8,6,7]}}; diff --git a/src/games/blockilization/models/r_bison.glb b/src/games/blockilization/models/r_bison.glb new file mode 100644 index 0000000000000000000000000000000000000000..c02d491d40cddb4074b04feb6b84384b77c0d34e GIT binary patch literal 13756 zcmeHOdvILUc|S61cY&*iWkkWLt=(nXq>Yevb^T84y^<}nW{f4%0%J?|H0=ZoN^T0nLwo!C zoqO+UTqN%eMTK&kKOZrk48(cYKp1xu(W(^j)B)tT<8 z**s;TrnkGhqo<~`drP{br@Hg*o=AUHBzy^${X2Cp+Mo7;P^qpC<3JmOMx zQ$LLOvr90x-PPW)C6fjU8)KXLt1jed+1$Nl5A@M{7^N}}mF~;&?#Q%vw)eK*<1wXr zdo%5uclC0*H`3I&epU0T#x-gOYZ}+xQMWp>Bvut^Zn$%KW8=CNEz!u5T7cNzwWYm{ zB04OldefQqREKgvx!@5lwfUA*Z%3*JF7Mc!xg*`1>gZ^1Ti3m7+g)7%ogQpX^`w_~ zcXVf(5vW}m;I^06()Ut5x97^NOtq<0fNvRJynyDjCSe<`kwT~%iN{E z>MPzDtHPyX*6VubZm}oTxuYYE#AMjJcV*g`0gTqA7GjrMs3mrugsZnRwQXg42hfcy zS-h}zF?QeHl_xA-w6H3&JCoY69#PPv3xNb@&EAa4+M(dmxuBxZlIqx;-qMX=Y~HnX zYdW)`J-s`!WFF0QrknTdV9qKPH9NYt;pkkHQLYw+b&HC^1*+zMT2U7t( zUR0def~XFsdsCThEZX|K#S1Vc$9hziEl8l4H*eg!Rn>I_X0ibDY+tVUvJ|MSRHuq6 z3se;Ppf6mEgK(iu)_kOIU32rgRrSf{hV{Uz0LFeC{z(G}T%pEKzbjATN_VC&iUOZP zS8DZ`M=1pKN;WrNpT~$_K4Ms<2R_<*ZFB)hSGu(YF+UUh+^Ma_NtzNjn7NXj&5K4C zP2Cyte9Bf9&LhW_Ze@|a2wJ8@DH=s_CdH2uvfkUusx>QCEiX(z9=WOM=SA2@JQl@H zrW%;Pcz$8NUL>%1G0It?N3rO~3dCSzs=y-k^5C90@5p02eyn1#*rIvLg975b*t{6b zequPc;$goz)wbO$RQ>&%`a{P)FGT&SHR34V1650+Y0|Q@j^kvrrnD_-VrXU^%a*pH zac)bYnOW0xG^yL@$RRaNqBnKLND=(mwoOKi*>p|Uypd&DF4fFxn&nc_a{vk$T|Ny> zQB?0Znr_>9eRNhBgQhX$-(}DdM%Xlh>Tb&N9?dy?=&-JZgMEAYgf${_hx?oW41wAg zObF=&V2hJIT2M%?X~DyN0+J9cL`B%r!bWh}nytGd+;xi^bf=oF3B@6>JC|hHx;Fxk zWoc>x9w~%w7;&Mai=(%7iK!7c1?-?V(An%ABMu(HHC@+j_D0+ZRbrxtD&0$mwos)b zMDq%G3>7I8{|q(e-D^OrLDyXMabwsLlA$rAv_#y5MfeKcG=LBa^w@$8e@sw}xP(U- z#0~6CDz3PpNllWZNkLIVhpX%{61r3)0Uqd)@WE8DC|p+1Q6U9<#*K^aja0T*zm!Ze zb=)LykSU12<>rRQ+#t-30HNT|6j^_6W`zlPsf;OJ&61Qyga6RWkU^;>l`15Jw|XI; zE9~)^I2wvCn?>RSJ*bnmCu(MykM@{(0?E_|-9gD*8$pga>ys`tPf8$5 zA9Xu~?U%;SNV98|xv&M3U;On}St|OX|tSx4?qB%JoM#|B84j+O;5^=yaLCXjp zExQz2Mkr_*0og;#Zm-$wu`(vYec`YOi=bgbTs3Tet!-&MeOb6*OI@guV_UZCl)Hkd z+6ZSFCft;2pnDiOHqNR~Mm2VsklZ#3kEs?Y4PndGNm8L+-S$f@iYsF5>Jo0bMYZ*n zGiCzG>Qp%L1S>y^^rh2jyGT4+UuR#8F5DCA6o7_y|{A8y#bzyKS- zx?>1qW)r+J@Mo&ty<_M$!iUr3r9A2(9AH3jp;8KHgiEU6Ln%(9R950JWBF8+U@2^6 zo>kmquq@#-K+1bQfE7ulIE*;Xji!bYfYEnccv9tA@icQ;Y1y(!cbVY1A%cli!5$ir z80sW(`%D#vR;3IEiomA`Yb!3DLp8!(7w(GyDzmQfz?sM+S5#s|9VQF`Ak=Li8cO5Y zVk<2)Np0ZLOu*S7PmG+R+!!(@H-UmF6LKP79UY)0oC(B-Ig1m zK6YyCL~bHCF(So~lN$r4N;tQCfq~J3c?pC}6FamVg%D$=fo7P1kWV>v*|3ID9OXm+ zX~fD<=~oH(y%L!P9nTF&;-9V|cU6Ys(Zp9M34Ry_&L}-N1FBgluq;=6d|AULB#n4T zLu`2hj#j`yB%T4WpnCM1h#aE_0-7O2m_w_-&$?d#fPPF+v8dmzQ+-x25!};fsO%ME z*I->W?i>kX!B1WxyzTf82Rt=2e?wSfK_y2ea!U(6g(2Y4w5%H7T>~t@UMY&l@yyKU zpMhl7v1-JJC1W24ie?tu%MN3!QVwHgLcPV2BluV!mS6%H7~<`dPX$7w9{6Cw!yN}~ zZL%pDOodHsBc+P3k4Qqj8%jrp!vGsW;$1KoapxO}E%)x*yALo*Ox%{NXs$ydBCc>S zAL*=)3CycY$D=@vphRSOGx3L!W(kwI4J=Gc=qj1vqmE~57Co8?>mutUE0SxGfx^iR zjhz@lMk6anQ2NlLl;+5(!ec6_o_^y9yT>#O28?wcZ!TyQbDU5pj#vNu^BGaHk);STm>hf`;Pu}@CRT!V zI3y~ifp0YAk%1;oG17Hd<)9Hq{k4qY zEP@EUtgMq&dqf$v$sczdBsR97aU_w>s(Jz}8|KgfXoz`bQl&6H1Yl5U%mNbx*0l*t zkm?XYa2hI-c-p{Cl zkmRUtlJai7gf8SaokHq@If(;k;c<=hvr(x~Wzq0S0Ditn3&Bu81>T4hDa$P?H(?eh z2JW^nf=?ZN>Sz+^n~4!4d`&pW4-|ox#7jO;M7*_y7mA{EN1Cwp))rO;yd4RAZzNH| zQf4Y>pumNpktg+1&G;dX$1)*~q8CSfj${Q!fJSYTo&q^`01@4#R(y$peu&9|{sBt# z5A@?M2b=>G*?)jb`w#5r(t-U4d}DQW$p*4fzkq%iVW?;cRFVm{yUeN2>#l(jd!vZ2 zhgKmyKw>}sV3K-Z*mGb2H28*_@1b%^2)HT4eh90EByYfgxuUG&uAGoG2+j-WAY&Oi z$>gtr_Aws&76vHQ5182Rcjy{irwyzK*$>ABU?ixVkqetaFI*hCIWTaG?6^_Ejo_X~ zS=>;O1QELd3mo*u?q2AlT7ycisSYdc&GdxZ z>V|OzP@xIsi;ou6ki|t;2??njgyc+Lk4uL}6-|Ny<8G$GELDJ(&q7QwW5fxlpB3&1 zchy+U=%IUqwn8uIkbss+*D>+EL|-t=X9VAXs0?NiF9D52g?B$bpfOq`s4oX`~m6XZ_47dr% zIKg}2xETN)s$u7A6*+HLOy-k|8 zoSMuDFmk_W%n5YN8+b#w*%5RY;qjA`h{IYDSm6*PTyHUjdywGN>o6e^j3B~n{-j}n zY zE^!GeFmb(9`+ywiN?OgS5IhV$l`AfoJ|LMcI(N}=G9Q_UL9E3hy6 z=Z6OqSjz(gK>|aA@VpQbUzFz-GHTx{GHS!_`Q@C#tjmB2ckkGNs9J-#p^j8WB}@T?l>?%tmZ-!5i5aAV~DAp>@`z*CRpr$$)f z0_%G-2IPi5Fnauo=q4tgPvo6E7viFZ?q ziVj9Kk(!ATk{GIEjK_Hl>#8e90XLbN>ew?L^>Z61N=_pVb4p6*o(7RInLB4Lk!Dop zl#!1tWi!t!#_`d)Wv7d&@O(+>_-IJ6)z4|9AphpfE&Vbb3XO`BAToyICylZg;a1;d z34|mSWUR`|1BgfvF$xT!4-5su2o%DHVQLVA6y}3Nn1sVafx*Ec>OctcAmJwlA6^V6 zLj&A>I>7aS#LrQFFf{x`>V*QIO&(R7l)})nfmPcNngy~q#?Ksi3w8XV!{@0-{Cz8+ zA&U7KFT3uXPiWna$Eh#H5Edj_tjKp!AlN{nOH*#7uDXSsl;gfUzo`Ms_ny)wlAv@T}ptie=|j#o{`t6D9Sm zlhrC>WtCiGjG{{^$TJhE+0XJg3Vv$iV(8Y4(gEI!F9xfm_~8o|h<74|qlB0sFsc|P z0{9sufPlE68)A7ZXE-m@r~|)9Okz+Es3vECYBDx}Dt`)aI5ZcxDW$6be;kE8$_%8B zGBoDL$b*7tU%{AhVwegcB5IG2z<~!|&ZW3)mx0w6X(?3Zk15mYsw^pP@~M-(tFw`? zUIy_`Oje<}0!;p|u)PvJ;%;U<;c04|f*OhVJx>S~m%uRYV&I(XziYkqWQ+h>ONwEa5+|F-<({?gCBY`2}O zd2-tiwm)(IjwRYJSDe|f{pmHg|HuD)^YHz@j{f@TxfQL?W!Gkp{M>$ON#&Z`-g&sF z=Iq5CO#`pur}7LNSnr3c@C;d@UcRvfMSBm2$aSxcXO_WJO> z@4VOfnS+b(J9qQX&))Z5|Gp2ddv4Rhe@=em(7E(2cbxtDs}1qN(1xndy)Rx~dCT8# z*fjmww#)-FZ>cwbRkq{A_TmMnrrlF9vU|8Tra_`^%z#R{gYkYiQBNbHDhg>5diW&TcOXg?{jj!Ov}ZtYZF8*XL$F`Qbe? z{^924b@uTazw`L)iKaa_{nKsw)6Nkwxa+qMzI*P(zce2kxqqkg@Ds1^yZy)bUv2h1 z{OCg;?W+3X@mD{4YIy5!n%;WxgTKw#KYjkTuYYUS%gj)cAx7GXgBL`pLy?W(Yv!>{CvmkzkB3y$NKQK2l}>ec;oflD<8b_ZRZQqqpx>3 zUz?b5>R8+M7gql8o6MP24Qp0BTi<#>e1e=3Q7@RuL5+9e?zZqOvz$et+c@OXBjj$fqwIXhZk=uC2D#o2LIw d>kUT^tc_l`@<)m3NODm$_OCBAlht?re*vee5zhbs literal 0 HcmV?d00001 diff --git a/src/games/blockilization/rules/mapgen.ts b/src/games/blockilization/rules/mapgen.ts index 97102a8..9cd8cb3 100644 --- a/src/games/blockilization/rules/mapgen.ts +++ b/src/games/blockilization/rules/mapgen.ts @@ -1,6 +1,7 @@ import { earthDry, earthSeas, earthLand, earthLatitude, earthRidges, earthRivers, earthWet, TRUE_STARTS, tileOfLonLat } from './earth'; import { distance, neighbour, neighbours, rowOf, colOf, within, type Grid } from './hex'; import { fitsSite, NATURAL_WONDER_LIST, type NaturalWonder } from './nature'; +import { placeLuxuries, placeStrategics, SEA_OIL, type LuxuryRoles } from './resources'; import { MAP_SIZES, sizeOfMap, type MapType, type Tile } from './state'; import { FEATURES, RESOURCES, TERRAINS, type FeatureId, type TerrainId } from './terrain'; @@ -84,6 +85,8 @@ export interface Generated { tiles: Tile[]; /** Good places to start, best first, spread out. */ starts: number[]; + /** How its luxuries were shared out (`resources.ts`). */ + luxuries: LuxuryRoles; } /** @@ -268,7 +271,13 @@ export function generate(g: Grid, seed: number, type: MapType, players: number, // --- Starting places, then resources (a fair few near each start), ruins. let starts = type === 'archipelago' ? pickIslandStarts(g, tiles, rng, players, cityStates, cellOf) : pickStarts(g, tiles, rng, players + cityStates); if (earth && trueStarts) starts = pickTrueStarts(g, tiles, trueStarts, starts); - placeResources(g, tiles, rng, starts.slice(0, players)); + // (The bonus resources first, as many as before; then the luxuries and the strategic resources, Civ V's.) + const civStarts = starts.slice(0, players).filter((t) => t >= 0); + const csStarts = starts.slice(players, players + cityStates).filter((t) => t >= 0); + placeResources(g, tiles, rng, civStarts); + const luxuries = placeLuxuries(g, tiles, rng, sizeOfMap(g), civStarts, csStarts); + placeStrategics(g, tiles, rng, civStarts, csStarts); + strategicBalance(g, tiles, rng, civStarts); for (let t = 0; t < n; t++) { const tile = tiles[t]; if (TERRAINS[tile.terrain].water || tile.elevation === 'mountain' || tile.resource) continue; @@ -285,9 +294,7 @@ export function generate(g: Grid, seed: number, type: MapType, players: number, } } placeNaturalWonders(g, tiles, rng, starts, players); - strategicBalance(g, tiles, rng, starts.slice(0, players)); - modernStrategics(g, tiles, rng, starts, starts.slice(players, players + cityStates).filter((t) => t >= 0)); - return { tiles, starts }; + return { tiles, starts, luxuries }; } /** @@ -318,7 +325,8 @@ export function placeVolcanoes(g: Grid, tiles: Tile[], seed: number, starts: num /** * Civ V's Strategic Balance: every civ's start has Horses and Iron within about four tiles (a near * spot first), and Coal and Oil on its own landmass within ten, unless there's no ground for them - * (Oil may lie just off the coast). Each is placed only where the civ has none in reach already. + * (Oil may lie just off the coast). Each is placed only where the civ has none in reach already, a + * large deposit (Civ V's). */ function strategicBalance(g: Grid, tiles: Tile[], rng: Rng, starts: number[]) { const fits = (t: number, id: string) => { @@ -333,8 +341,7 @@ function strategicBalance(g: Grid, tiles: Tile[], rng: Rng, starts: number[]) { if (!options.length) return; const t = rng.pick(options); tiles[t].resource = id; - const amount = RESOURCES[id].amount; - if (amount) tiles[t].amount = rng.int(amount[0], amount[1]); + tiles[t].amount = TERRAINS[tiles[t].terrain].water ? SEA_OIL : RESOURCES[id].amount![1]; }; for (const s of starts) { const ground = (r: number) => within(g, s, r).filter((t) => t !== s && !TERRAINS[tiles[t].terrain].water); @@ -347,48 +354,6 @@ function strategicBalance(g: Grid, tiles: Tile[], rng: Rng, starts: number[]) { } } -/** - * Civ V's modern strategics, the last of a new world's resources: Aluminum about as common across the - * land as Iron or Oil (commoner than Oil, in Civ V), Uranium about half as common, each scattered - * where it belongs and near no start in particular, so many to the tiles that suit it (as Civ V - * counts them, so a map is never without); and three city-states in four a small deposit of Coal, - * Oil or Aluminum beside them (Civ V's `AddModernMinorStrategicsToCityStates`). (Left to the roll in - * `placeResources` alone they were rare: a small map often had no Aluminum at all, and so no Jet - * Fighters, Stealth Bombers, Modern Armor, Hydro Plants or Spaceship Factories.) - */ -function modernStrategics(g: Grid, tiles: Tile[], rng: Rng, starts: number[], cityStates: number[]) { - const fits = (t: number, id: string) => { - const tile = tiles[t]; - if (tile.resource || tile.ruin || tile.wonder || tile.antiquity || starts.includes(t) || tile.elevation === 'mountain' || tile.feature === 'ice' || tile.feature === 'oasis') return false; - return RESOURCES[id].on.some((on) => on === tile.terrain || on === tile.feature || on === tile.elevation); - }; - const place = (t: number, id: string, small = false) => { - const [lo, hi] = RESOURCES[id].amount!; - tiles[t].resource = id; - tiles[t].amount = small ? lo : rng.int(lo, hi); - }; - for (const [id, share] of Object.entries(MODERN_SHARE)) { - const spots = tiles.map((_, t) => t).filter((t) => fits(t, id)); - // (Spread out: none beside another of its kind.) - for (let left = Math.ceil(spots.length * share); left > 0 && spots.length; ) { - const t = spots.splice(Math.floor(rng.next() * spots.length), 1)[0]; - if (neighbours(g, t).some((n) => tiles[n].resource === id)) continue; - place(t, id); - left--; - } - } - for (const cs of cityStates) { - const roll = rng.int(0, 3); - if (!roll) continue; - const id = (['coal', 'oil', 'aluminum'] as const)[roll - 1]; - const spots = within(g, cs, 3).filter((t) => t !== cs && fits(t, id)); - if (spots.length) place(rng.pick(spots), id, true); - } -} - -/** The share of the tiles free for it that get a deposit, in `modernStrategics`. */ -const MODERN_SHARE = { aluminum: 0.03, uranium: 0.02 }; - /** The land tiles joined to a tile by land (mountains included). */ function landmass(g: Grid, tiles: Tile[], from: number): Set { const out = new Set([from]); @@ -633,21 +598,21 @@ function landmassesOf(g: Grid, land: boolean[]): number[] { return out; } -/** Resources across the map, each kind where it belongs; a couple of food and luxuries near each start. */ +/** + * The bonus resources across the map, each kind where it belongs, and one near each start (the + * luxuries and strategic resources are Civ V's, before them: `resources.ts`). + */ function placeResources(g: Grid, tiles: Tile[], rng: Rng, starts: number[]) { const fits = (t: number, id: string) => { const tile = tiles[t]; const r = RESOURCES[id]; if (tile.resource || tile.elevation === 'mountain' || tile.feature === 'ice' || tile.feature === 'oasis') return false; + if (r.open && (tile.feature || tile.elevation !== 'flat')) return false; return r.on.some((on) => on === tile.terrain || on === tile.feature || on === tile.elevation); }; const ids = Object.keys(RESOURCES); - const place = (t: number, id: string) => { - const r = RESOURCES[id]; - tiles[t].resource = id; - if (r.amount) tiles[t].amount = rng.int(r.amount[0], r.amount[1]); - }; - // Every tile has a chance, weighted by kind. + // Every tile has a chance, weighted by kind: a bonus kept, a luxury or strategic one passed over + // (as many bonuses as when the roll placed them all). for (let t = 0; t < tiles.length; t++) { if (!rng.chance(0.12)) continue; const options = ids.filter((id) => fits(t, id)); @@ -655,7 +620,7 @@ function placeResources(g: Grid, tiles: Tile[], rng: Rng, starts: number[]) { const total = options.reduce((s, id) => s + RESOURCES[id].weight, 0); let roll = rng.next() * total; for (const id of options) if ((roll -= RESOURCES[id].weight) < 0) { - place(t, id); + if (RESOURCES[id].kind === 'bonus') tiles[t].resource = id; break; } } @@ -665,20 +630,17 @@ function placeResources(g: Grid, tiles: Tile[], rng: Rng, starts: number[]) { for (let t = 0; t < tiles.length; t++) { if (tiles[t].resource || !rng.chance(TERRAINS[tiles[t].terrain].water ? 0.03 : 0.07)) continue; const options = food.filter((id) => fits(t, id)); - if (options.length) place(t, rng.pick(options)); + if (options.length) tiles[t].resource = rng.pick(options); } - // Near each start: a food resource and a luxury, if there's room for them. + // Near each start: a food resource, if there's room for one. for (const s of starts) { - const ring = within(g, s, 3).filter((t) => t !== s && distance(g, s, t) >= 1); - for (const kind of ['bonus', 'luxury', 'luxury'] as const) { - const spots = ring.filter((t) => !tiles[t].resource); - for (let tries = 0; tries < 20 && spots.length; tries++) { - const t = rng.pick(spots); - const options = ids.filter((id) => RESOURCES[id].kind === kind && fits(t, id)); - if (!options.length) continue; - place(t, rng.pick(options)); - break; - } + const spots = within(g, s, 3).filter((t) => t !== s && !tiles[t].resource); + for (let tries = 0; tries < 20 && spots.length; tries++) { + const t = rng.pick(spots); + const options = ids.filter((id) => RESOURCES[id].kind === 'bonus' && fits(t, id)); + if (!options.length) continue; + tiles[t].resource = rng.pick(options); + break; } } } diff --git a/src/games/blockilization/rules/resources.ts b/src/games/blockilization/rules/resources.ts new file mode 100644 index 0000000..f67ca54 --- /dev/null +++ b/src/games/blockilization/rules/resources.ts @@ -0,0 +1,555 @@ +import { distance, neighbours, within, type Grid } from './hex'; +import type { Rng } from './mapgen'; +import type { MapSize, Tile } from './state'; +import { RESOURCES, TERRAINS } from './terrain'; + +/** + * A new world's luxuries and strategic resources, placed as Civ V places them (Brave New World's + * `AssignStartingPlots.lua`, its PlaceLuxuries and PlaceStrategicAndBonusResources). + * + * Luxuries by region: each civ's region (the land nearer its start than any other's) is given a type + * that suits its ground (furs and whales in the tundra, incense and gold in the desert, wine and ivory + * on the plains, and so on), one beside its start and a few more about it; three other types are + * kept for the city-states; a few are left off the map (more on a small one); the rest are scattered + * at random, and each start has one of those too, a second type. So a civ has plenty of one luxury + * and has to trade for the others, as in Civ V. + * + * Strategic resources in deposits of Civ V's sizes: large ones (Iron 6, Horses 4, Coal 7, Oil 7, + * Aluminum 8, Uranium 4), one to so many tiles of each kind of ground, of the kinds that belong there + * (Oil and Uranium in the marshes, Iron, Coal and Aluminum in the hills, Horses on open grassland and + * plains); small ones (2 or 3) one to every 23 tiles of flat land; a small Coal, Oil or Aluminum by + * three city-states in four; Oil off the coast, half as much as on the land; and a large one more of + * any the map has too little of. Each deposit keeps the next of any kind a tile or few off it (Civ + * V's "impact"), so they spread out. + */ + +/** The kinds of ground Civ V sorts the tiles into for its resources (luxuries by its fifteen, `GetIndicesForLuxuryType`; strategics by a few more). */ +type Ground = + | 'coast' // coast beside land + | 'sea' // any coast + | 'marsh' + | 'floodplains' + | 'open_hills' + | 'covered_hills' // hills under forest or jungle + | 'jungle_hills' + | 'forest_hills' + | 'hills' // all hills but snowy ones + | 'jungle' // flat + | 'forest' // flat + | 'temperate_forest' // flat forest not on tundra + | 'desert' // flat, open (no oasis or flood plains) + | 'plains' // flat, open + | 'dry_grass' // flat, open, no fresh water + | 'wet_grass' // flat, open, fresh water + | 'tundra' // flat, open or forest + | 'open_tundra' // flat, open + | 'snow' // flat + | 'land'; // flat land: open, forest or jungle (not marsh or flood plains) + +/** Where each luxury goes, best first (Civ V's). */ +const LUXURY_GROUND: Record = { + whales: ['coast'], + pearls: ['coast'], + crab: ['coast'], + gold_ore: ['open_hills', 'desert', 'covered_hills'], + silver: ['open_hills', 'covered_hills', 'tundra', 'dry_grass'], + gems: ['jungle_hills', 'forest_hills', 'open_hills', 'jungle'], + marble: ['dry_grass', 'desert', 'plains', 'open_hills'], + ivory: ['plains', 'dry_grass'], + furs: ['tundra', 'temperate_forest'], + dyes: ['forest', 'jungle', 'marsh'], + spices: ['jungle', 'temperate_forest', 'marsh'], + silk: ['temperate_forest', 'jungle'], + sugar: ['marsh', 'jungle', 'floodplains', 'wet_grass'], + cotton: ['floodplains', 'wet_grass', 'dry_grass'], + wine: ['plains', 'dry_grass', 'wet_grass'], + incense: ['desert', 'floodplains', 'plains'], + copper: ['open_hills', 'covered_hills', 'dry_grass', 'tundra'], + salt: ['plains', 'desert', 'tundra', 'forest'], + citrus: ['jungle', 'jungle_hills', 'temperate_forest', 'floodplains'], + truffles: ['temperate_forest', 'jungle', 'marsh', 'covered_hills'], + cocoa: ['jungle', 'jungle_hills', 'temperate_forest'], +}; + +/** Where Marble goes once the rest are placed (Civ V keeps it apart: it speeds wonders). */ +const MARBLE_GROUND: Ground[] = ['open_hills', 'open_tundra', 'desert', 'plains', 'dry_grass']; + +/** Civ V's regions, by what most of their land is (`DetermineRegionTypes`). */ +type RegionKind = 'tundra' | 'jungle' | 'forest' | 'desert' | 'hills' | 'plains' | 'grass' | 'hybrid' | 'none'; +const REGION_ORDER: RegionKind[] = ['tundra', 'jungle', 'forest', 'desert', 'hills', 'plains', 'grass', 'hybrid', 'none']; + +/** How likely each luxury is to be a region's, by its kind (Civ V's `luxury_region_weights`). */ +const REGION_WEIGHTS: Record, [string, number][]> = { + tundra: [['furs', 40], ['whales', 35], ['crab', 30], ['silver', 25], ['copper', 15], ['salt', 15], ['gems', 5], ['dyes', 5]], + jungle: [['cocoa', 35], ['citrus', 35], ['spices', 30], ['gems', 20], ['sugar', 20], ['pearls', 20], ['copper', 5], ['truffles', 5], ['crab', 5], ['silk', 5], ['dyes', 5]], + forest: [['dyes', 30], ['silk', 30], ['truffles', 30], ['furs', 10], ['spices', 10], ['citrus', 5], ['salt', 5], ['copper', 5], ['cocoa', 5], ['crab', 10], ['whales', 10], ['pearls', 10]], + desert: [['incense', 35], ['salt', 15], ['gold_ore', 25], ['copper', 10], ['cotton', 15], ['sugar', 15], ['pearls', 5], ['citrus', 5]], + hills: [['gold_ore', 30], ['silver', 30], ['copper', 30], ['gems', 15], ['pearls', 15], ['salt', 10], ['crab', 10], ['whales', 10]], + plains: [['ivory', 35], ['wine', 35], ['salt', 25], ['incense', 10], ['spices', 5], ['whales', 5], ['pearls', 5], ['crab', 5], ['truffles', 5], ['gold_ore', 5]], + grass: [['cotton', 30], ['silver', 20], ['sugar', 20], ['copper', 20], ['crab', 20], ['pearls', 10], ['whales', 10], ['cocoa', 10], ['truffles', 5], ['spices', 5], ['gems', 5]], + hybrid: [['ivory', 15], ['cotton', 15], ['wine', 15], ['silver', 10], ['salt', 15], ['copper', 20], ['whales', 20], ['pearls', 20], ['crab', 20], ['truffles', 10], ['cocoa', 10], ['spices', 5], ['sugar', 5], ['citrus', 5], ['incense', 5], ['silk', 5], ['gems', 5], ['gold_ore', 5]], +}; + +/** Every luxury but Marble, as likely as Civ V has them where nothing else decides (`luxury_fallback_weights`): the types left off the map and those scattered at random are from these. */ +const FALLBACK_WEIGHTS: [string, number][] = [['whales', 10], ['pearls', 10], ['gold_ore', 10], ['silver', 5], ['gems', 10], ['ivory', 5], ['furs', 10], ['dyes', 5], ['spices', 5], ['silk', 5], ['sugar', 5], ['cotton', 5], ['wine', 5], ['incense', 5], ['copper', 5], ['salt', 5], ['citrus', 5], ['truffles', 5], ['cocoa', 5], ['crab', 5]]; + +/** The city-states' own types, those civs have least of the likelier (`luxury_city_state_weights`). */ +const CITY_STATE_WEIGHTS: [string, number][] = [['whales', 15], ['pearls', 15], ['gold_ore', 10], ['silver', 10], ['gems', 10], ['ivory', 10], ['furs', 15], ['dyes', 10], ['spices', 15], ['silk', 15], ['sugar', 10], ['cotton', 10], ['wine', 10], ['incense', 15], ['copper', 10], ['salt', 10], ['citrus', 15], ['truffles', 15], ['cocoa', 10], ['crab', 15]]; + +const WATER_LUXURIES = new Set(['whales', 'pearls', 'crab']); + +/** A deposit of Oil out at sea (Civ V's). */ +export const SEA_OIL = 4; + +/** + * Civ V's luxury numbers for a map's size: those of its own world size for as many civs (a duel map's + * Duel's, a small one's Tiny's, a standard one's Small's, a large one's Standard's, a huge one's + * Large's). Each region's copies of its type (by how many civs there are), the luxuries the world + * should have (but for each start's second type) and the least of each random type, and the types + * left off the map. + */ +const LUXURY_COUNTS: Record = { + duel: { region: new Array(22).fill(1), world: 18, least: 3, disabled: 11 }, + small: { region: [0, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], world: 30, least: 4, disabled: 8 }, + standard: { region: [0, 3, 3, 3, 4, 4, 4, 3, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], world: 45, least: 4, disabled: 6 }, + large: { region: [0, 3, 3, 4, 4, 5, 5, 6, 5, 4, 4, 3, 3, 2, 2, 1, 1, 1, 1, 1, 1, 1], world: 60, least: 5, disabled: 4 }, + huge: { region: [0, 3, 4, 4, 5, 5, 5, 6, 6, 7, 6, 5, 5, 4, 4, 3, 3, 2, 2, 2, 2, 2], world: 75, least: 5, disabled: 2 }, +}; + +/** How the random types share what's left to place, the first the most (Civ V's `random_lux_ratios_table`). */ +const RANDOM_SHARES = [[1], [0.55, 0.45], [0.4, 0.33, 0.27], [0.35, 0.25, 0.25, 0.15], [0.25, 0.25, 0.2, 0.15, 0.15], [0.2, 0.2, 0.2, 0.15, 0.15, 0.1], [0.2, 0.2, 0.15, 0.15, 0.1, 0.1, 0.1], [0.2, 0.15, 0.15, 0.1, 0.1, 0.1, 0.1, 0.1]]; + +/** How the luxuries were shared out: each civ's region's type, the city-states' types, those at random, those left off. */ +export interface LuxuryRoles { + regions: (string | null)[]; + cityStates: string[]; + random: string[]; + disabled: string[]; +} + +/** The tiles' grounds and a world's resources set on them (`placeLuxuries`, `placeStrategics`). */ +class Placer { + readonly grounds: Ground[][]; + /** Civ V's impact layers: where a luxury or a strategic resource has been put, and how far it keeps the next. */ + readonly luxury: number[]; + readonly strategic: number[]; + readonly marble: number[]; + private readonly lists = new Map(); + + constructor( + readonly g: Grid, + readonly tiles: Tile[], + readonly rng: Rng, + readonly blocked: Set, + ) { + const n = tiles.length; + this.luxury = new Array(n).fill(0); + this.strategic = new Array(n).fill(0); + this.marble = new Array(n).fill(0); + this.grounds = tiles.map((_, t) => (blocked.has(t) ? [] : groundsOf(g, tiles, t))); + } + + /** All the tiles of a ground (each list shuffled once, as Civ V's are). */ + list(ground: Ground): number[] { + let out = this.lists.get(ground); + if (!out) { + out = this.shuffled(this.grounds.flatMap((gs, t) => (gs.includes(ground) ? [t] : []))); + this.lists.set(ground, out); + } + return out; + } + + /** The tiles of a ground within so many of a tile (its own aside), shuffled. */ + near(t: number, radius: number, ground: Ground): number[] { + return this.shuffled(within(this.g, t, radius).filter((x) => x !== t && this.grounds[x].includes(ground))); + } + + shuffled(list: T[]): T[] { + const out = [...list]; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(this.rng.next() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; + } + + free(t: number): boolean { + return !this.tiles[t].resource && !this.blocked.has(t); + } + + put(t: number, id: string, amount?: number) { + this.tiles[t].resource = id; + if (amount) this.tiles[t].amount = amount; + } + + /** Civ V's impact: the tile marked, and the rings round it out to `radius`, each less (more where they overlap). */ + mark(layer: number[], t: number, radius: number) { + layer[t] = layer === this.marble ? 1 : 99; + for (const x of within(this.g, t, radius)) { + if (x === t) continue; + if (layer === this.marble) { + layer[x] = 1; + continue; + } + const ripple = radius - distance(this.g, t, x) + 1; + layer[x] = layer[x] > 0 ? Math.min(50, Math.max(layer[x], ripple) + 2) : ripple; + } + } + + /** + * Civ V's PlaceSpecificNumberOfResources: up to `count` of a resource on the first free tiles of a list + * (no more than `ratio` of the list), each clear of the layer's marks if one's given (and marking it, + * `minR` to `maxR` round). How many weren't placed. + */ + some(id: string, count: number, ratio: number, list: number[], layer?: number[], minR = 0, maxR = 0, amount?: number): number { + let left = count; + const n = Math.min(count, Math.ceil(ratio * list.length)); + for (let i = 0; i < n; i++) { + const t = list.find((x) => this.free(x) && (!layer || layer[x] === 0)); + if (t === undefined) break; + this.put(t, id, amount); + left--; + if (layer) this.mark(layer, t, minR + (maxR > minR ? this.rng.int(0, maxR - minR) : 0)); + } + return left; + } + + /** `some` from a resource's grounds in turn (each one's tiles from `listOf`), with a ratio each, until all are placed. */ + inGrounds(id: string, count: number, grounds: Ground[], listOf: (ground: Ground) => number[], ratios: number[], layer?: number[], radii?: [number, number][], amount?: number): number { + let left = count; + grounds.forEach((ground, i) => { + if (left > 0) left = this.some(id, left, ratios[Math.min(i, ratios.length - 1)], listOf(ground), layer, radii?.[i]?.[0], radii?.[i]?.[1], amount); + }); + return left; + } + + /** Whether a luxury could go within so many tiles of a site (Civ V's `GetListOfAllowableLuxuriesAtCitySite`). */ + allowedNear(t: number, radius: number, id: string): boolean { + return within(this.g, t, radius).some((x) => x !== t && this.free(x) && LUXURY_GROUND[id].some((gr) => this.grounds[x].includes(gr))); + } +} + +/** A tile's grounds (Civ V's `GenerateGlobalResourcePlotLists`). */ +function groundsOf(g: Grid, tiles: Tile[], t: number): Ground[] { + const tile = tiles[t]; + if (tile.resource || tile.elevation === 'mountain' || tile.feature === 'ice' || tile.feature === 'atoll') return []; + if (tile.terrain === 'coast') return neighbours(g, t).some((n) => !TERRAINS[tiles[n].terrain].water) ? ['coast', 'sea'] : ['sea']; + if (TERRAINS[tile.terrain].water) return []; + if (tile.elevation === 'hills') { + if (tile.terrain === 'snow') return []; + if (!tile.feature) return ['open_hills', 'hills']; + if (tile.feature === 'jungle') return ['covered_hills', 'jungle_hills', 'hills']; + if (tile.feature === 'forest') return ['covered_hills', 'forest_hills', 'hills']; + return ['hills']; + } + if (tile.feature === 'marsh') return ['marsh']; + if (tile.feature === 'floodplains') return ['floodplains']; + if (tile.feature === 'jungle') return ['jungle', 'land']; + if (tile.feature === 'forest') return tile.terrain === 'tundra' ? ['forest', 'tundra', 'land'] : ['forest', 'temperate_forest', 'land']; + if (tile.feature) return []; + if (tile.terrain === 'tundra') return ['tundra', 'open_tundra', 'land']; + if (tile.terrain === 'snow') return ['snow', 'land']; + if (tile.terrain === 'desert') return ['desert', 'land']; + if (tile.terrain === 'plains') return ['plains', 'land']; + return [freshWater(g, tiles, t) ? 'wet_grass' : 'dry_grass', 'land']; +} + +/** A river on it, or a lake or an oasis beside it. */ +function freshWater(g: Grid, tiles: Tile[], t: number): boolean { + return !!tiles[t].river || neighbours(g, t).some((n) => tiles[n].terrain === 'lake' || tiles[n].feature === 'oasis' || !!tiles[n].river); +} + +/** Each tile's region: the civ whose start it's nearest by land (none on land no civ starts on), and each coast beside its land. */ +function regionsOf(g: Grid, tiles: Tile[], starts: number[]): number[] { + const water = (t: number) => !!TERRAINS[tiles[t].terrain].water; + const region: number[] = new Array(tiles.length).fill(-1); + const queue: number[] = []; + starts.forEach((s, i) => { + if (s >= 0 && region[s] < 0) { + region[s] = i; + queue.push(s); + } + }); + for (let i = 0; i < queue.length; i++) + for (const n of neighbours(g, queue[i])) + if (region[n] < 0 && !water(n)) { + region[n] = region[queue[i]]; + queue.push(n); + } + tiles.forEach((tile, t) => { + if (tile.terrain !== 'coast') return; + const by = neighbours(g, t).find((n) => !water(n) && region[n] >= 0); + if (by !== undefined) region[t] = region[by]; + }); + return region; +} + +/** What a region's land mostly is (Civ V's tests, in its order). */ +function regionKind(tiles: Tile[], own: number[]): RegionKind { + const land = own.filter((t) => !TERRAINS[tiles[t].terrain].water); + const area = land.length; + const count = (f: (tile: Tile) => boolean) => land.filter((t) => f(tiles[t])).length; + const cold = count((x) => x.terrain === 'tundra' || x.terrain === 'snow'); + const jungle = count((x) => x.feature === 'jungle'); + const forest = count((x) => x.feature === 'forest'); + const desert = count((x) => x.terrain === 'desert'); + const hills = count((x) => x.elevation === 'hills'); + const plains = count((x) => x.terrain === 'plains'); + const grass = count((x) => x.terrain === 'grassland'); + const peaks = count((x) => x.elevation === 'mountain'); + if (!area) return 'none'; + if (cold >= area * 0.3) return 'tundra'; + if (jungle >= area * 0.3 || (jungle >= area * 0.2 && jungle + forest >= area * 0.35)) return 'jungle'; + if (forest >= area * 0.3 || (forest >= area * 0.2 && jungle + forest >= area * 0.35)) return 'forest'; + if (desert >= area * 0.25) return 'desert'; + if (hills >= area * 0.415) return 'hills'; + if (plains >= area * 0.3 && plains * 0.7 > grass) return 'plains'; + if (grass >= area * 0.3 && grass * 0.7 > plains) return 'grass'; + if (grass + plains + desert + cold + hills + peaks > area * 0.8) return 'hybrid'; + return 'none'; +} + +/** A pick by weight (none from an empty list). */ +function pickWeighted(rng: Rng, options: [string, number][]): string | null { + const total = options.reduce((s, [, w]) => s + w, 0); + if (!options.length || total <= 0) return null; + let roll = rng.next() * total; + for (const [id, w] of options) if ((roll -= w) < 0) return id; + return options[options.length - 1][0]; +} + +/** + * The luxuries, as Civ V shares them out (`AssignLuxuryRoles`) and places them (`PlaceLuxuries`): each + * region's type by its kind of land (no more than eight types for the regions, each a region's alone + * until there are more than eight civs; a sea luxury only where the start is on the coast with water + * enough), then three types for the city-states and a few left off, the rest at random. Then one of + * each region's type by its start (within two tiles, or three), one by each city-state (three times in + * four one of their own types), the rest of each region's about it, the random types across the map + * up to the world's count, a second type by each start, and Marble. + */ +export function placeLuxuries(g: Grid, tiles: Tile[], rng: Rng, size: MapSize, starts: number[], cityStates: number[]): LuxuryRoles { + const placer = new Placer(g, tiles, rng, new Set([...starts, ...cityStates])); + const civs = starts.length; + const counts = LUXURY_COUNTS[size]; + const region = regionsOf(g, tiles, starts); + const own = starts.map((_, i) => region.flatMap((r, t) => (r === i ? [t] : []))); + const kinds = own.map((tilesOf) => regionKind(tiles, tilesOf)); + const coastal = starts.map((s) => neighbours(g, s).some((n) => tiles[n].terrain === 'coast')); + const water = own.map((tilesOf) => tilesOf.filter((t) => tiles[t].terrain === 'coast').length); + + // --- Roles. Each region a type, the regions in order of their kind (each kind's in a random order). + const order = REGION_ORDER.flatMap((k) => placer.shuffled(starts.map((_, i) => i).filter((i) => kinds[i] === k))); + const assigned = new Map(); + const regions: (string | null)[] = starts.map(() => null); + const split = civs > 12 ? 3 : civs > 8 ? 2 : 1; + for (const i of order) { + const kind = kinds[i]; + // (And, unlike Civ V, only a type with ground for it by the start, if any has: else a civ now and + // then had none of its own type there, given a random one instead.) + const canTake = (id: string, cap: number, strict: boolean) => { + if ((assigned.get(id) ?? 0) >= cap) return false; + if (assigned.size >= 8 && !assigned.has(id)) return false; + if (!strict) return true; + if (WATER_LUXURIES.has(id)) { + if ((id === 'whales' && kind === 'jungle') || (id === 'pearls' && kind === 'tundra') || (id === 'crab' && kind === 'desert')) return false; + if (!coastal[i] || water[i] < 12) return false; + } + return placer.allowedNear(starts[i], 3, id); + }; + const weigh = (list: [string, number][], cap: number, strict: boolean): [string, number][] => list.filter(([id]) => canTake(id, cap, strict)).map(([id, w]) => [id, w / (1 + (assigned.get(id) ?? 0))]); + let options = weigh(kind === 'none' ? FALLBACK_WEIGHTS : REGION_WEIGHTS[kind], split, true); + if (!options.length) options = weigh(FALLBACK_WEIGHTS, split, true); + if (!options.length) options = weigh(FALLBACK_WEIGHTS, 3, true); + if (!options.length) options = weigh(FALLBACK_WEIGHTS, 3, false); + const id = pickWeighted(rng, options); + if (!id) continue; + regions[i] = id; + assigned.set(id, (assigned.get(id) ?? 0) + 1); + } + const cityStateTypes: string[] = []; + const csOptions = CITY_STATE_WEIGHTS.filter(([id]) => !assigned.has(id)); + for (let k = 0; k < 3; k++) { + const id = pickWeighted(rng, csOptions); + if (!id) break; + cityStateTypes.push(id); + csOptions.splice(csOptions.findIndex(([x]) => x === id), 1); + } + const rest = placer.shuffled(FALLBACK_WEIGHTS.map(([id]) => id).filter((id) => !assigned.has(id) && !cityStateTypes.includes(id))); + const disabled = rest.slice(0, Math.min(rest.length, counts.disabled)); + const random = rest.slice(disabled.length); + const placed = () => tiles.filter((tile) => tile.resource && RESOURCES[tile.resource].kind === 'luxury').length; + const at = (t: number, radius: number) => (ground: Ground) => placer.near(t, radius, ground); + + // --- At each start: one of its region's type (or, with no room for it, one of a random type). + starts.forEach((s, i) => { + const id = regions[i]; + if (!id) return; + let left = placer.inGrounds(id, 1, LUXURY_GROUND[id], at(s, 2), [0.5]); + if (left) left = placer.inGrounds(id, left, LUXURY_GROUND[id], at(s, 3), [1]); + if (!left) return; + let randoms = 1; + for (const r of random) if (randoms > 0) randoms = placer.inGrounds(r, randoms, LUXURY_GROUND[r], at(s, 3), [1]); + }); + + // --- At each city-state: one of their own types three times in four (where there's ground for it), else a random one or its region's. + for (const cs of cityStates) { + const options: [string, number][] = []; + const theirOwn = cityStateTypes.filter((id) => placer.allowedNear(cs, 2, id)); + for (const id of theirOwn) options.push([id, 75 / theirOwn.length]); + const theirs = region[cs] >= 0 ? regions[region[cs]] : null; + const randoms = random.filter((id) => placer.allowedNear(cs, 2, id)); + const share = randoms.length + (theirs ? 1 : 0); + for (const id of randoms) options.push([id, 25 / share]); + if (theirs && placer.allowedNear(cs, 2, theirs)) options.push([theirs, 25 / share]); + const id = pickWeighted(rng, options); + if (id) placer.inGrounds(id, 1, LUXURY_GROUND[id], at(cs, 2), [1]); + } + + // --- The rest of each region's type, about it (shared out where regions share a type). + const inRegion = (i: number) => (ground: Ground) => placer.list(ground).filter((t) => region[t] === i); + starts.forEach((_, i) => { + const id = regions[i]; + if (!id) return; + const want = Math.max(1, Math.floor(counts.region[Math.min(civs, counts.region.length) - 1] / (assigned.get(id) ?? 1))); + placer.inGrounds(id, want, LUXURY_GROUND[id], inRegion(i), [0.3, 0.3, 0.4, 0.5], placer.luxury, [[0, 3], [0, 3], [0, 2], [0, 2]]); + }); + + // --- The random types across the map, up to the world's count (a few of each at least). + if (random.length) { + const target = counts.world + rng.int(0, Math.max(0, civs - 1)) - placed(); + random.forEach((id, k) => { + const many = + random.length * 3 > target ? 3 : random.length > 8 ? Math.max(3, Math.ceil(target / 10)) : Math.max(Math.max(3, counts.least - (k + 1)), Math.ceil(target * RANDOM_SHARES[random.length - 1][k])); + placer.inGrounds(id, many, LUXURY_GROUND[id], (ground) => placer.list(ground), [0.25, 0.25, 0.25, 0.3], placer.luxury, [[4, 6], [4, 6], [4, 6], [4, 6]]); + }); + } + + // --- A second type by each start: a random type (or Marble) if one fits, else a city-states', else another region's. + starts.forEach((s, i) => { + let options = [...random, 'marble'].filter((id) => placer.allowedNear(s, 2, id)); + if (!options.length) options = cityStateTypes.filter((id) => placer.allowedNear(s, 2, id)); + if (!options.length) options = [...assigned.keys()].filter((id) => id !== regions[i] && placer.allowedNear(s, 2, id)); + if (!options.length) return; + const id = options[rng.int(0, options.length - 1)]; + placer.inGrounds(id, 1, LUXURY_GROUND[id], at(s, 2), [1]); + }); + + // --- Marble: three for every four civs, apart from each other and the other luxuries. + const marbles = Math.max(2, Math.ceil(civs * 0.75) - tiles.filter((tile) => tile.resource === 'marble').length); + const marbleSpots = placer.shuffled(MARBLE_GROUND.flatMap((ground) => placer.list(ground)).filter((t, k, all) => all.indexOf(t) === k)); + for (let k = 0; k < marbles; k++) { + const t = marbleSpots.find((x) => placer.free(x) && placer.marble[x] === 0 && placer.luxury[x] === 0); + if (t === undefined) break; + placer.put(t, 'marble'); + placer.mark(placer.luxury, t, 1); + placer.mark(placer.marble, t, 6); + } + return { regions, cityStates: cityStateTypes, random, disabled }; +} + +/** + * The strategic resources, as Civ V places them (`PlaceStrategicAndBonusResources`): large deposits on + * the grounds that suit each, one to so many of those tiles, each a kind by its weight there; a small + * Coal, Oil or Aluminum by three city-states in four; small deposits on flat land, one to 23 tiles, + * each a kind the tile suits; Oil off the coast, half as much as on the land; and a large deposit more + * where the map has too little of a kind (under four for each civ, or eight of Iron and Coal; Uranium + * under two a civ). + */ +export function placeStrategics(g: Grid, tiles: Tile[], rng: Rng, starts: number[], cityStates: number[]) { + const placer = new Placer(g, tiles, rng, new Set([...starts, ...cityStates])); + const large = (id: string) => RESOURCES[id].amount![1]; + const small = (id: string) => RESOURCES[id].amount![0]; + // Civ V's ProcessResourceList: so many to the tiles of a ground, each of a kind by weight, the first + // free tile clear of the others' marks taken (or, once there's none, the one marked least). + const process = (every: number, ground: Ground, kinds: [id: string, weight: number, minR: number, maxR: number][]) => { + const list = placer.list(ground); + const many = Math.ceil(list.length / every); + let next = 0; + for (let k = 0; k < many; k++) { + const pick = pickWeighted(rng, kinds.map(([id, w]) => [id, w]))!; + const [id, , minR, maxR] = kinds.find(([x]) => x === pick)!; + let t = -1; + while (next < list.length && t < 0) { + const x = list[next++]; + if (placer.free(x) && placer.strategic[x] === 0) t = x; + } + if (t < 0) { + let least = 98; + for (const x of list) + if (placer.free(x) && placer.strategic[x] < least) { + least = placer.strategic[x]; + t = x; + } + } + if (t < 0) continue; + placer.put(t, id, large(id)); + placer.mark(placer.strategic, t, minR + (maxR > minR ? rng.int(0, maxR - minR) : 0)); + } + }; + process(9, 'marsh', [['oil', 65, 1, 1], ['uranium', 35, 0, 1]]); + process(16, 'open_tundra', [['oil', 40, 1, 2], ['aluminum', 15, 1, 2], ['iron', 45, 1, 2]]); + process(17, 'snow', [['oil', 60, 1, 1], ['aluminum', 15, 2, 3], ['iron', 25, 2, 3]]); + process(13, 'desert', [['oil', 65, 0, 1], ['iron', 35, 1, 1]]); + process(22, 'hills', [['iron', 26, 0, 2], ['coal', 35, 1, 3], ['aluminum', 39, 2, 3]]); + process(33, 'jungle', [['coal', 30, 1, 2], ['uranium', 70, 1, 2]]); + process(39, 'forest', [['coal', 30, 1, 2], ['uranium', 70, 1, 1]]); + process(33, 'dry_grass', [['horses', 100, 2, 5]]); + process(33, 'plains', [['horses', 100, 1, 4]]); + + // By three city-states in four: a small Coal, Oil or Aluminum within three tiles. + const minor: Record = { + coal: ['open_hills', 'covered_hills', 'tundra', 'dry_grass', 'plains', 'desert'], + oil: ['desert', 'marsh', 'tundra', 'temperate_forest', 'dry_grass', 'plains'], + aluminum: ['open_hills', 'covered_hills', 'tundra', 'desert', 'plains', 'dry_grass'], + }; + for (const cs of cityStates) { + const roll = rng.int(0, 3); + if (!roll) continue; + const id = (['coal', 'oil', 'aluminum'] as const)[roll - 1]; + placer.inGrounds(id, 1, minor[id], (ground) => placer.near(cs, 3, ground), [1], undefined, undefined, small(id)); + } + + // Small deposits on the flat land, one to 23 tiles, clear of the others' marks: a kind the tile suits. + const land = placer.list('land'); + let next = 0; + for (let k = Math.ceil(land.length / 23); k > 0 && next < land.length; k--) { + let t = -1; + while (next < land.length && t < 0) { + const x = land[next++]; + if (placer.free(x) && placer.strategic[x] === 0) t = x; + } + if (t < 0) break; + const id = smallKind(g, tiles, rng, t); + placer.put(t, id, small(id)); + const radius = rng.int(0, 3); + placer.mark(placer.strategic, t, radius > 2 ? 1 : radius); + } + + // Oil off the coast, half as much as on the land, in deposits of 4 spread well apart. + const total = (id: string) => tiles.reduce((s, tile) => s + (tile.resource === id ? (tile.amount ?? 0) : 0), 0); + placer.some('oil', Math.floor(total('oil') / 2 / SEA_OIL), 0.2, placer.list('sea'), placer.strategic, 4, 7, SEA_OIL); + + // Too little of a kind: a large deposit more. + const civs = starts.length; + const more = (id: string, ground: Ground) => process(99999, ground, [[id, 100, 0, 0]]); + if (total('iron') < 8) more('iron', 'hills'); + if (total('iron') < 4 * civs) more('iron', 'land'); + if (total('horses') < 4 * civs) more('horses', 'plains'); + if (total('horses') < 4 * civs) more('horses', 'dry_grass'); + if (total('coal') < 8) more('coal', 'hills'); + if (total('coal') < 4 * civs) more('coal', 'land'); + if (total('oil') < 4 * civs) more('oil', 'land'); + if (total('aluminum') < 4 * civs) more('aluminum', 'hills'); + if (total('uranium') < 2 * civs) more('uranium', 'land'); +} + +/** The kind of a small deposit on a tile of flat land (Civ V's `PlaceSmallQuantitiesOfStrategics`). */ +function smallKind(g: Grid, tiles: Tile[], rng: Rng, t: number): string { + const tile = tiles[t]; + if (tile.feature === 'jungle') return ['oil', 'coal', 'aluminum', 'aluminum'][rng.int(0, 3)]; + if (tile.feature === 'forest') return ['uranium', 'coal', 'iron', 'iron'][rng.int(0, 3)]; + if (tile.terrain === 'grassland') return freshWater(g, tiles, t) || rng.int(0, 4) >= 3 ? 'horses' : 'iron'; + if (tile.terrain === 'plains') return rng.int(0, 4) < 2 ? 'iron' : 'horses'; + if (tile.terrain === 'desert') return ['iron', 'aluminum', 'oil'][rng.int(0, 2)]; + return ['iron', 'uranium', 'oil', 'oil'][rng.int(0, 3)]; +} diff --git a/src/games/blockilization/rules/terrain.ts b/src/games/blockilization/rules/terrain.ts index 1b14033..c51b841 100644 --- a/src/games/blockilization/rules/terrain.ts +++ b/src/games/blockilization/rules/terrain.ts @@ -107,10 +107,12 @@ export interface Resource { reveal?: string; /** Where it's found: terrains, features, elevations. */ on: (TerrainId | FeatureId | Elevation)[]; - /** How common, relative to others of its kind. */ + /** Only on open flat land (no hills, nothing growing there). */ + open?: boolean; + /** How common a bonus resource is, relative to the others (`mapgen.ts`; the rest are placed as Civ V places them, `resources.ts`). */ weight: number; - /** A strategic resource's quantity per deposit (a unit needs one each). */ - amount?: [number, number]; + /** A strategic resource's quantity in a small deposit and in a large one (Civ V's; a unit needs one each). */ + amount?: [small: number, large: number]; /** Its colour on the map (and its icon's). */ color: string; } @@ -119,42 +121,43 @@ export type ImprovementId = 'farm' | 'mine' | 'trading_post' | 'lumber_mill' | ' export const RESOURCES: Record = { // Bonus - wheat: { name: 'Wheat', kind: 'bonus', yields: { food: 1 }, improvement: 'farm', improved: { food: 1 }, on: ['plains', 'floodplains', 'desert'], weight: 10, color: '#e8c95a' }, + wheat: { name: 'Wheat', kind: 'bonus', yields: { food: 1 }, improvement: 'farm', improved: {}, on: ['plains', 'floodplains', 'desert'], weight: 10, color: '#e8c95a' }, cattle: { name: 'Cattle', kind: 'bonus', yields: { food: 1 }, improvement: 'pasture', improved: { prod: 1 }, on: ['grassland'], weight: 10, color: '#8a5a3a' }, - sheep: { name: 'Sheep', kind: 'bonus', yields: { food: 1 }, improvement: 'pasture', improved: { prod: 1 }, on: ['hills'], weight: 10, color: '#f0eee6' }, + sheep: { name: 'Sheep', kind: 'bonus', yields: { food: 1 }, improvement: 'pasture', improved: { food: 1 }, on: ['hills'], weight: 10, color: '#f0eee6' }, deer: { name: 'Deer', kind: 'bonus', yields: { food: 1 }, improvement: 'camp', improved: { prod: 1 }, on: ['forest', 'tundra'], weight: 10, color: '#b07a4a' }, bananas: { name: 'Bananas', kind: 'bonus', yields: { food: 1 }, improvement: 'plantation', improved: { food: 1 }, on: ['jungle'], weight: 8, color: '#f2d43a' }, - fish: { name: 'Fish', kind: 'bonus', yields: { food: 2 }, improvement: 'fishing_boats', improved: { food: 1 }, on: ['coast'], weight: 12, color: '#7ac8e8' }, + fish: { name: 'Fish', kind: 'bonus', yields: { food: 1 }, improvement: 'fishing_boats', improved: { food: 1 }, on: ['coast'], weight: 12, color: '#7ac8e8' }, + bison: { name: 'Bison', kind: 'bonus', yields: { food: 1 }, improvement: 'camp', improved: { prod: 1 }, on: ['plains', 'grassland'], open: true, weight: 5, color: '#6a4a2e' }, stone: { name: 'Stone', kind: 'bonus', yields: { prod: 1 }, improvement: 'quarry', improved: { prod: 1 }, on: ['grassland', 'plains', 'desert', 'tundra'], weight: 6, color: '#9a9a9a' }, // Strategic horses: { name: 'Horses', kind: 'strategic', yields: { prod: 1 }, improvement: 'pasture', improved: { prod: 1 }, reveal: 'animal_husbandry', on: ['grassland', 'plains', 'tundra'], weight: 10, amount: [2, 4], color: '#c49a6c' }, - iron: { name: 'Iron', kind: 'strategic', yields: { prod: 1 }, improvement: 'mine', improved: { prod: 1 }, reveal: 'bronze_working', on: ['hills', 'grassland', 'plains', 'desert', 'tundra', 'snow'], weight: 10, amount: [2, 6], color: '#7a8a9a' }, - coal: { name: 'Coal', kind: 'strategic', yields: { prod: 1 }, improvement: 'mine', improved: { prod: 1 }, reveal: 'industrialization', on: ['hills', 'grassland', 'plains'], weight: 8, amount: [2, 6], color: '#a0a0ac' }, - oil: { name: 'Oil', kind: 'strategic', yields: { prod: 1 }, improvement: 'oil_well', improved: { prod: 1 }, reveal: 'biology', on: ['desert', 'tundra', 'snow', 'marsh', 'jungle', 'coast'], weight: 8, amount: [2, 6], color: '#9a88c8' }, - aluminum: { name: 'Aluminum', kind: 'strategic', yields: { prod: 1 }, improvement: 'mine', improved: { prod: 1 }, reveal: 'electricity', on: ['hills', 'plains', 'desert', 'tundra'], weight: 6, amount: [2, 6], color: '#d8dde3' }, - uranium: { name: 'Uranium', kind: 'strategic', yields: { prod: 1 }, improvement: 'mine', improved: { prod: 1 }, reveal: 'atomic_theory', on: ['hills', 'tundra', 'desert', 'forest', 'jungle', 'marsh'], weight: 4, amount: [1, 4], color: '#7aff3a' }, + iron: { name: 'Iron', kind: 'strategic', yields: { prod: 1 }, improvement: 'mine', improved: {}, reveal: 'bronze_working', on: ['hills', 'grassland', 'plains', 'desert', 'tundra', 'snow', 'forest'], weight: 10, amount: [2, 6], color: '#7a8a9a' }, + coal: { name: 'Coal', kind: 'strategic', yields: { prod: 1 }, improvement: 'mine', improved: { prod: 1 }, reveal: 'industrialization', on: ['hills', 'forest', 'jungle', 'grassland', 'plains'], weight: 8, amount: [3, 7], color: '#a0a0ac' }, + oil: { name: 'Oil', kind: 'strategic', yields: { prod: 1 }, improvement: 'oil_well', improved: { prod: 3 }, reveal: 'biology', on: ['desert', 'tundra', 'snow', 'marsh', 'jungle', 'coast'], weight: 8, amount: [3, 7], color: '#9a88c8' }, + aluminum: { name: 'Aluminum', kind: 'strategic', yields: { prod: 1 }, improvement: 'mine', improved: { prod: 1 }, reveal: 'electricity', on: ['hills', 'tundra', 'snow', 'desert', 'jungle'], weight: 6, amount: [3, 8], color: '#d8dde3' }, + uranium: { name: 'Uranium', kind: 'strategic', yields: { prod: 1 }, improvement: 'mine', improved: { prod: 1 }, reveal: 'atomic_theory', on: ['marsh', 'jungle', 'forest', 'tundra', 'snow'], weight: 4, amount: [2, 4], color: '#7aff3a' }, // Luxury - gold_ore: { name: 'Gold', kind: 'luxury', yields: { gold: 2 }, improvement: 'mine', improved: { gold: 1 }, on: ['hills', 'desert', 'plains', 'grassland'], weight: 6, color: '#ffc531' }, - silver: { name: 'Silver', kind: 'luxury', yields: { gold: 2 }, improvement: 'mine', improved: { gold: 1 }, on: ['hills', 'desert', 'tundra'], weight: 6, color: '#cfd6dc' }, - gems: { name: 'Gems', kind: 'luxury', yields: { gold: 3 }, improvement: 'mine', improved: { gold: 1 }, on: ['jungle', 'hills', 'desert', 'grassland', 'plains', 'tundra'], weight: 6, color: '#3ad0ff' }, - marble: { name: 'Marble', kind: 'luxury', yields: { gold: 2 }, improvement: 'quarry', improved: { prod: 1 }, on: ['grassland', 'plains', 'desert', 'tundra', 'hills'], weight: 5, color: '#f4f1ea' }, - copper: { name: 'Copper', kind: 'luxury', yields: { gold: 2 }, improvement: 'mine', improved: { gold: 1 }, on: ['hills', 'desert', 'plains', 'grassland', 'tundra'], weight: 5, color: '#d8844a' }, - salt: { name: 'Salt', kind: 'luxury', yields: { food: 1, gold: 1 }, improvement: 'mine', improved: { prod: 1 }, on: ['plains', 'desert', 'tundra'], weight: 5, color: '#f8f4ec' }, - ivory: { name: 'Ivory', kind: 'luxury', yields: { gold: 2 }, improvement: 'camp', improved: { prod: 1 }, on: ['plains'], weight: 5, color: '#f4ecd4' }, - furs: { name: 'Furs', kind: 'luxury', yields: { gold: 2 }, improvement: 'camp', improved: { prod: 1 }, on: ['tundra', 'forest'], weight: 5, color: '#8a5a2a' }, - truffles: { name: 'Truffles', kind: 'luxury', yields: { gold: 2 }, improvement: 'camp', improved: { prod: 1 }, on: ['forest', 'jungle', 'marsh'], weight: 4, color: '#5a3a2a' }, - dyes: { name: 'Dyes', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['jungle', 'forest'], weight: 5, color: '#b03ad0' }, - spices: { name: 'Spices', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['jungle', 'forest'], weight: 5, color: '#d8642a' }, - silk: { name: 'Silk', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['forest'], weight: 5, color: '#ff8ab6' }, - sugar: { name: 'Sugar', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['floodplains', 'marsh'], weight: 5, color: '#ffffff' }, - cotton: { name: 'Cotton', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['grassland', 'plains', 'desert'], weight: 5, color: '#f4f4f4' }, + gold_ore: { name: 'Gold', kind: 'luxury', yields: { gold: 2 }, improvement: 'mine', improved: {}, on: ['hills', 'desert'], weight: 6, color: '#ffc531' }, + silver: { name: 'Silver', kind: 'luxury', yields: { gold: 2 }, improvement: 'mine', improved: {}, on: ['hills', 'tundra', 'grassland'], weight: 6, color: '#cfd6dc' }, + gems: { name: 'Gems', kind: 'luxury', yields: { gold: 3 }, improvement: 'mine', improved: {}, on: ['hills', 'jungle'], weight: 6, color: '#3ad0ff' }, + marble: { name: 'Marble', kind: 'luxury', yields: { gold: 2 }, improvement: 'quarry', improved: { prod: 1 }, on: ['grassland', 'desert', 'plains', 'tundra', 'hills'], weight: 5, color: '#f4f1ea' }, + copper: { name: 'Copper', kind: 'luxury', yields: { gold: 2 }, improvement: 'mine', improved: {}, on: ['hills', 'grassland', 'tundra'], weight: 5, color: '#d8844a' }, + salt: { name: 'Salt', kind: 'luxury', yields: { food: 1, gold: 1 }, improvement: 'mine', improved: { food: 1 }, on: ['plains', 'desert', 'tundra', 'forest'], weight: 5, color: '#f8f4ec' }, + ivory: { name: 'Ivory', kind: 'luxury', yields: { gold: 2 }, improvement: 'camp', improved: { gold: 1 }, on: ['plains', 'grassland'], weight: 5, color: '#f4ecd4' }, + furs: { name: 'Furs', kind: 'luxury', yields: { gold: 2 }, improvement: 'camp', improved: { gold: 1 }, on: ['tundra', 'forest'], weight: 5, color: '#8a5a2a' }, + truffles: { name: 'Truffles', kind: 'luxury', yields: { gold: 2 }, improvement: 'camp', improved: { gold: 1 }, on: ['forest', 'jungle', 'marsh', 'hills'], weight: 4, color: '#5a3a2a' }, + dyes: { name: 'Dyes', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['forest', 'jungle', 'marsh'], weight: 5, color: '#b03ad0' }, + spices: { name: 'Spices', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['jungle', 'forest', 'marsh'], weight: 5, color: '#d8642a' }, + silk: { name: 'Silk', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['forest', 'jungle'], weight: 5, color: '#ff8ab6' }, + sugar: { name: 'Sugar', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['marsh', 'jungle', 'floodplains', 'grassland'], weight: 5, color: '#ffffff' }, + cotton: { name: 'Cotton', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['floodplains', 'grassland'], weight: 5, color: '#f4f4f4' }, wine: { name: 'Wine', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['grassland', 'plains'], weight: 5, color: '#8a1a3a' }, - incense: { name: 'Incense', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['desert', 'plains'], weight: 5, color: '#c4a46a' }, - citrus: { name: 'Citrus', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['grassland', 'plains', 'jungle'], weight: 4, color: '#ff9a1a' }, - cocoa: { name: 'Cocoa', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['jungle', 'forest'], weight: 4, color: '#6a3a1a' }, + incense: { name: 'Incense', kind: 'luxury', yields: { gold: 2 }, improvement: 'plantation', improved: { gold: 1 }, on: ['desert', 'floodplains', 'plains'], weight: 5, color: '#c4a46a' }, + citrus: { name: 'Citrus', kind: 'luxury', yields: { food: 1, gold: 1 }, improvement: 'plantation', improved: { gold: 1 }, on: ['jungle', 'forest', 'floodplains'], weight: 4, color: '#ff9a1a' }, + cocoa: { name: 'Cocoa', kind: 'luxury', yields: { food: 1, gold: 1 }, improvement: 'plantation', improved: { gold: 1 }, on: ['jungle', 'forest'], weight: 4, color: '#6a3a1a' }, whales: { name: 'Whales', kind: 'luxury', yields: { food: 1, gold: 1 }, improvement: 'fishing_boats', improved: { food: 1 }, on: ['coast'], weight: 4, color: '#3a5a7a' }, pearls: { name: 'Pearls', kind: 'luxury', yields: { gold: 2 }, improvement: 'fishing_boats', improved: { food: 1 }, on: ['coast'], weight: 4, color: '#f4f8ff' }, - crab: { name: 'Crab', kind: 'luxury', yields: { gold: 2 }, improvement: 'fishing_boats', improved: { food: 1 }, on: ['coast'], weight: 3, color: '#e8543a' }, + crab: { name: 'Crab', kind: 'luxury', yields: { food: 1, gold: 1 }, improvement: 'fishing_boats', improved: { food: 1 }, on: ['coast'], weight: 3, color: '#e8543a' }, }; /** The improvement that taps a tile's resource: the resource's own, but Oil out at sea takes an Offshore Platform (a work boat's, with Refrigeration). */ diff --git a/src/games/blockilization/tests/cases.ts b/src/games/blockilization/tests/cases.ts index 7fda7ba..bd958e3 100644 --- a/src/games/blockilization/tests/cases.ts +++ b/src/games/blockilization/tests/cases.ts @@ -16,7 +16,8 @@ import { ANARCHY_TURNS, apply, applyOnce, blockers, canBuild, workTurns, constru import { colOf, distance, line, neighbour, neighbours, rowOf, tileAt, within } from '../rules/hex'; import { generate, Rng, START_ISLAND } from '../rules/mapgen'; import { NATURAL_WONDERS } from '../rules/nature'; -import { IMPROVEMENTS, TERRAINS, type Elevation, type FeatureId, type ImprovementId } from '../rules/terrain'; +import { IMPROVEMENTS, RESOURCES, TERRAINS, YIELD_KEYS, type Elevation, type FeatureId, type ImprovementId, type Yields } from '../rules/terrain'; +import { SEA_OIL } from '../rules/resources'; import { makeCtx, militaryAt } from '../rules/ai/context'; import { opinionOf } from '../rules/ai/opinion'; import { planWar } from '../rules/ai/strategy'; @@ -3238,30 +3239,135 @@ test('map generation: Horses and Iron within four tiles of every civ’s start, ok(coal >= starts * 0.9 && oil >= starts * 0.9, `Coal (${coal}) and Oil (${oil}) within reach of nearly all ${starts} starts`); }); -test('map generation: Aluminum and Uranium across every map, as in Civ V (about as much Aluminum as Iron, Uranium about half), and most city-states with a little Coal, Oil or Aluminum beside them', () => { - const total = { iron: 0, aluminum: 0, uranium: 0 }; +test('map generation: strategic resources as Civ V places them, each deposit a small or a large one of its kind (Iron 2 or 6, Horses 2 or 4, Coal and Oil 3 or 7, Aluminum 3 or 8, Uranium 2 or 4; Oil at sea 4), every kind on every map, and most city-states with a little Coal, Oil or Aluminum beside them', () => { let cityStates = 0; let supplied = 0; - for (const size of ['duel', 'small', 'standard'] as MapSize[]) + for (const size of ['duel', 'small', 'standard', 'huge'] as MapSize[]) for (const type of ['continents', 'pangaea', 'archipelago', 'fractal'] as const) - for (const seed of [1, 2, 3, 4]) { + for (const seed of [1, 2]) { const m = MAP_SIZES[size]; const g = { width: m.width, height: m.height, wrap: true }; const map = generate(g, seed, type, m.players, m.cityStates); - const count = (id: string) => map.tiles.filter((t) => t.resource === id).length; - // (Left to chance before, a small map often had no Aluminum at all.) - ok(count('aluminum') > 0 && count('uranium') > 0, `${size} ${type} ${seed}: Aluminum (${count('aluminum')}) and Uranium (${count('uranium')}) somewhere`); - for (const id of ['iron', 'aluminum', 'uranium'] as const) total[id] += count(id); + const what = `${size} ${type} ${seed}`; + const units: Record = {}; + map.tiles.forEach((tile, t) => { + const r = tile.resource && RESOURCES[tile.resource]; + if (!r || r.kind !== 'strategic') return; + units[tile.resource!] = (units[tile.resource!] ?? 0) + (tile.amount ?? 0); + const sea = !!TERRAINS[tile.terrain].water; + ok(sea ? tile.resource === 'oil' && tile.amount === SEA_OIL : r.amount!.includes(tile.amount!), `${what}: ${r.name} at ${t}, ${tile.amount}, a deposit of its size`); + }); + // (Civ V tops up a kind the map has too little of: a civ always has some within reach.) + for (const id of ['iron', 'horses', 'coal', 'oil', 'aluminum', 'uranium']) ok((units[id] ?? 0) >= (id === 'uranium' ? 1.5 : 2) * m.players, `${what}: ${RESOURCES[id].name} enough for every civ (${units[id] ?? 0} for ${m.players})`); for (const cs of map.starts.slice(m.players, m.players + m.cityStates)) { cityStates++; if (within(g, cs, 3).some((t) => ['coal', 'oil', 'aluminum'].includes(map.tiles[t].resource ?? ''))) supplied++; } } - ok(total.aluminum >= total.iron * 0.75 && total.aluminum <= total.iron * 1.5, `about as much Aluminum (${total.aluminum}) as Iron (${total.iron})`); - ok(total.uranium >= total.aluminum * 0.35 && total.uranium <= total.aluminum * 0.8, `Uranium (${total.uranium}) about half as common as Aluminum`); ok(supplied >= cityStates * 0.7, `most city-states (${supplied} of ${cityStates}) with Coal, Oil or Aluminum beside them`); }); +test('map generation: luxuries by region, as Civ V shares them out: each civ’s region a type of its own (up to eight civs), one by its start and more about it, and a second type there; three types the city-states’ alone; some left off the map; about as many as Civ V’s worlds have', () => { + // (Civ V's luxuries for the world of as many civs, before each start's second type: Duel, Tiny, Small, Standard, Large.) + const world: Record = { duel: 18, small: 30, standard: 45, large: 60, huge: 75 }; + for (const size of ['duel', 'small', 'standard', 'large', 'huge'] as MapSize[]) + for (const type of ['continents', 'pangaea', 'archipelago', 'earth'] as const) + for (const seed of [1, 2]) { + const m = MAP_SIZES[size]; + const g = { width: m.width, height: m.height, wrap: true }; + const map = generate(g, seed, type, m.players, m.cityStates); + const what = `${size} ${type} ${seed}`; + const { regions, cityStates, disabled } = map.luxuries; + const civs = map.starts.slice(0, m.players); + const cities = [...civs, ...map.starts.slice(m.players, m.players + m.cityStates).filter((t) => t >= 0)]; + const copies = (id: string) => map.tiles.filter((x) => x.resource === id).length; + ok(regions.every(Boolean), `${what}: every region a type`); + if (m.players <= 8) eq(new Set(regions).size, regions.length, `${what}: each region’s its own`); + civs.forEach((s, i) => { + const near = new Set(within(g, s, 3).flatMap((t) => (map.tiles[t].resource && RESOURCES[map.tiles[t].resource!].kind === 'luxury' ? [map.tiles[t].resource!] : []))); + ok(near.has(regions[i]!), `${what}: ${regions[i]} by civ ${i}’s start`); + ok(near.size >= 2, `${what}: and a second type there (${[...near]})`); + ok(copies(regions[i]!) >= 2, `${what}: more ${regions[i]} about it (${copies(regions[i]!)})`); + }); + eq(cityStates.length, 3, `${what}: three types for the city-states`); + map.tiles.forEach((x, t) => { + if (x.resource && cityStates.includes(x.resource)) ok(cities.some((c) => distance(g, c, t) <= 2), `${what}: ${x.resource} at ${t} by a city-state (or, wanting another, a start)`); + }); + ok(disabled.length > 0 && disabled.every((id) => !copies(id)), `${what}: ${disabled.join(', ')} left off the map`); + const luxuries = map.tiles.filter((x) => x.resource && RESOURCES[x.resource].kind === 'luxury').length; + ok(luxuries >= world[size] * 0.9 && luxuries <= world[size] * 1.6, `${what}: ${luxuries} luxuries (Civ V’s world for as many civs, ${world[size]}, and a second type at each start)`); + } +}); + +test('resources yield as Civ V’s (Brave New World): what each gives, and what improving it adds (the improvement’s own yield and the resource’s with it)', () => { + const { game, at } = blank(); + const t = at(6, 6); + const tile = game.tiles[t]; + const words = (y: Yields) => YIELD_KEYS.flatMap((k) => (y[k] ? [`${y[k]} ${k}`] : [])).join(', ') || 'nothing'; + const less = (a: Yields, b: Yields): Yields => Object.fromEntries(YIELD_KEYS.map((k) => [k, (a[k] ?? 0) - (b[k] ?? 0)])); + const CIV5: Record = { + wheat: [{ food: 1 }, { food: 1 }], + cattle: [{ food: 1 }, { prod: 1 }], + sheep: [{ food: 1 }, { food: 1 }], + deer: [{ food: 1 }, { prod: 1 }], + bison: [{ food: 1 }, { prod: 1 }], + fish: [{ food: 1 }, { food: 1 }], + stone: [{ prod: 1 }, { prod: 1 }], + horses: [{ prod: 1 }, { prod: 1 }], + iron: [{ prod: 1 }, { prod: 1 }], + coal: [{ prod: 1 }, { prod: 2 }], + aluminum: [{ prod: 1 }, { prod: 2 }], + uranium: [{ prod: 1 }, { prod: 2 }], + oil: [{ prod: 1 }, { prod: 3 }], + gold_ore: [{ gold: 2 }, { prod: 1 }], + silver: [{ gold: 2 }, { prod: 1 }], + copper: [{ gold: 2 }, { prod: 1 }], + gems: [{ gold: 3 }, { prod: 1 }], + salt: [{ food: 1, gold: 1 }, { food: 1, prod: 1 }], + marble: [{ gold: 2 }, { prod: 1 }], + ivory: [{ gold: 2 }, { gold: 1 }], + furs: [{ gold: 2 }, { gold: 1 }], + truffles: [{ gold: 2 }, { gold: 1 }], + dyes: [{ gold: 2 }, { gold: 1 }], + spices: [{ gold: 2 }, { gold: 1 }], + silk: [{ gold: 2 }, { gold: 1 }], + sugar: [{ gold: 2 }, { gold: 1 }], + cotton: [{ gold: 2 }, { gold: 1 }], + wine: [{ gold: 2 }, { gold: 1 }], + incense: [{ gold: 2 }, { gold: 1 }], + citrus: [{ food: 1, gold: 1 }, { gold: 1 }], + cocoa: [{ food: 1, gold: 1 }, { gold: 1 }], + whales: [{ food: 1, gold: 1 }, { food: 1 }], + pearls: [{ gold: 2 }, { food: 1 }], + crab: [{ food: 1, gold: 1 }, { food: 1 }], + }; + const plain = tileYields(game, t, null); + for (const [id, [own, improved]] of Object.entries(CIV5)) { + tile.resource = id; + delete tile.improvement; + const bare = tileYields(game, t, null); + eq(words(less(bare, plain)), words(own), `${RESOURCES[id].name}: its own yield`); + tile.improvement = RESOURCES[id].improvement; + eq(words(less(tileYields(game, t, null), bare)), words(improved), `${RESOURCES[id].name}: improved with a ${IMPROVEMENTS[RESOURCES[id].improvement].name}`); + } +}); + +test('Bison, a bonus resource (Brave New World’s): on open flat plains and grassland, worked from a camp; drawn on its tiles, with its icon', () => { + let found = 0; + for (const seed of [1, 2, 3]) { + const m = MAP_SIZES.standard; + const map = generate({ width: m.width, height: m.height, wrap: true }, seed, 'continents', m.players, m.cityStates); + for (const tile of map.tiles) { + if (tile.resource !== 'bison') continue; + found++; + ok(['plains', 'grassland'].includes(tile.terrain) && tile.elevation === 'flat' && !tile.feature, `on open flat land (${tile.terrain}, ${tile.elevation}, ${tile.feature ?? 'nothing on it'})`); + } + } + ok(found >= 3, `Bison on the maps (${found})`); + eq(RESOURCES.bison.improvement, 'camp', 'a camp taps it'); + ok(!!KINDS.bison && glyphPaths('bison') !== glyphPaths(''), 'its models on its tiles, and an icon'); +}); + test('map generation: an archipelago is many islands, the civs mostly each on their own, each with room for three or four cities', () => { for (const wrap of [false, true]) for (const size of ['small', 'standard', 'large'] as MapSize[]) { @@ -3870,6 +3976,21 @@ test('the replay after a load carries on where it was (nothing twice); a turn co eq(JSON.stringify(loaded.replay).length - size, 3 + 3 * moved, `a turn's record: three characters, and three for each of the ${moved} tiles that changed hands`); }); +test('the replay of a huge map (more tiles than two digits number): its borders recorded too, a tile in three digits', () => { + const { game, at } = blank(['egypt', 'babylon'], { size: 'huge' }); + ok(game.tiles.length > 64 * 64, `${game.tiles.length} tiles`); + city(game, 0, at(4, 4)); + city(game, 1, at(80, 50)); + endTurn(game); + const owners = replayAt(game.replay!, game.tiles.length, Infinity).owners; + ok(game.tiles.every((tile, t) => owners[t] === tile.owner), 'every tile’s owner as it is, the far corner’s too'); + const size = JSON.stringify(game.replay).length; + const before = game.tiles.map((tile) => tile.owner); + endTurn(game); + const moved = game.tiles.filter((tile, t) => tile.owner !== before[t]).length; + eq(JSON.stringify(game.replay).length - size, 3 + 4 * moved, `a turn's record: three characters, and four for each of the ${moved} tiles that changed hands`); +}); + test('saved games are each person’s, by account: listed in any room, carried on anywhere, never replaced by a new game; room saves moved over', () => { // A store as `game.store` is: every room's keys together. const data = new Map(); diff --git a/src/games/blockilization/tools/resources.mjs b/src/games/blockilization/tools/resources.mjs index 5172228..25e2eec 100644 --- a/src/games/blockilization/tools/resources.mjs +++ b/src/games/blockilization/tools/resources.mjs @@ -1,7 +1,7 @@ /** * Blockilization's resources as little voxel models, built in code: what a tile's resource is, seen * on the tile itself (each screen places them, `client/resources3d.ts`). Grazing beasts (cattle, - * sheep, horses, deer, an elephant for ivory, a fox for furs, a pig snuffling for truffles), crops + * sheep, horses, deer, bison, an elephant for ivory, a fox for furs, a pig snuffling for truffles), crops * and trees (wheat, bananas, cotton, dyes, spices, mulberry and cocoons for silk, sugar cane, vines, * frankincense, citrus, cocoa), the ground's riches (boulders, ores in their rock, marble, salt, an * oil seep, bauxite, uranium glowing green), and the sea's (a school of fish, a whale's back, crabs @@ -26,7 +26,7 @@ const COLOURS = { emerald: 0x30c070, amethyst: 0xb060f0, vein: 0xa4a4b0, copperOre: 0xe0843a, salt: 0xfbf9f4, saltShade: 0xe2dad2, saltFlat: 0xe6ded2, dye: 0xb03ad8, indigo: 0x4a42c0, chili: 0xd82a1a, spice: 0xf0802a, mulberry: 0x3a1a3a, cocoon: 0xf8f4e6, cane: 0xb0c850, caneNode: 0x76863a, cotton: 0xfcfbf6, cottonShade: 0xe4e0d6, vine: 0x4a8a34, grape: 0x5a2470, resin: 0xf0a030, incenseBark: 0x8e7c62, - citrus: 0xff9418, cocoaPod: 0xe8a820, cocoaRed: 0xb44a1a, + citrus: 0xff9418, cocoaPod: 0xe8a820, cocoaRed: 0xb44a1a, bison: 0x6e4a2c, bisonDark: 0x3e2a1c, bisonShag: 0x2a1c14, bisonHorn: 0x2e2a28, }; /** How much each colour glows (the rest don't). */ const GLOW = { uranium: 0.6, goldBright: 0.35, ruby: 0.3, sapphire: 0.3, emerald: 0.3, amethyst: 0.3, pearl: 0.25, resin: 0.2 }; @@ -162,6 +162,21 @@ function horse(id, dark = false) { return m; } +/** A bison: a great shaggy hump and mane over its shoulders, a brown body behind, its bearded head low, short dark horns. */ +function bison(id) { + const m = new Model(id); + body(m, { w: 6, h: 5, len: 11, legs: 4, leg: 2, coat: 'bison', shank: 'bisonDark', hoof: 'bisonHorn' }); + const shag = (i, j, k) => (hash(i, j, k, 40) < 0.35 ? 'bisonShag' : 'bisonDark'); + blob(m, 0, 9, 2.5, 3.4, 2.8, 3.2, shag, 8); + fill(m, [-3, 4, 2], [3, 9, 6], shag); + m.box([-2, 3, 6], [2, 8, 9], 'bisonDark'); + m.box([-1, 1, 7], [1, 3, 9], 'bisonShag'); + m.box([-3, 7, 6], [-2, 8, 8], 'bisonHorn').box([2, 7, 6], [3, 8, 8], 'bisonHorn'); + m.box([-3, 8, 7], [-2, 9, 8], 'bisonHorn').box([2, 8, 7], [3, 9, 8], 'bisonHorn'); + m.box([0, 6, -6], [1, 9, -5], 'bisonDark'); + return m; +} + /** A red deer: slender on long legs, a pale belly and rump, antlers. */ function deer(id) { const m = new Model(id); @@ -565,6 +580,7 @@ export const RESOURCE_MODELS = [ horse('r_horses'), horse('r_horses2', true), deer('r_deer'), + bison('r_bison'), elephant('r_ivory'), fox('r_furs'), pig('r_truffles'), From be3f035a46d89850ea00d72738c4cd0d064cebde Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:41:52 +0000 Subject: [PATCH 4/4] Blockilization: test the Impi's and the Cossack's own promotions So effects-coverage sees Against Gunpowder and Stronger Against the Wounded read, as it did every effect before they were given out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJp3oDGF1p3b1w297oSHYf --- src/games/blockilization/tests/cases.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/games/blockilization/tests/cases.ts b/src/games/blockilization/tests/cases.ts index bd958e3..a3c3b63 100644 --- a/src/games/blockilization/tests/cases.ts +++ b/src/games/blockilization/tests/cases.ts @@ -7303,6 +7303,13 @@ test('combat bonuses, Civ V’s: spears +50% against horses, anti-tank +100% aga ok(promotionsOf(samurai).includes('shock_1') && !samurai.promotions.includes('shock_1'), 'a samurai is born with Shock I'); eq(promotionsDue(samurai), 1, 'which doesn’t use up its experience'); ok(!promotionsFor(samurai).some((x) => x.id === 'shock_1'), 'nor is offered again'); + const impi = unit(game, 'impi', 0, at(2, 18)); + const musket = unit(game, 'musketman', 1, at(3, 18)); + eq(mod(impi, 'attack', musket, 'Against Gunpowder'), 25, 'an impi against muskets'); + const cossack = unit(game, 'cossack', 0, at(6, 18)); + eq(mod(cossack, 'attack', musket, 'Stronger Against the Wounded'), 0, 'a cossack against a whole unit'); + musket.hp = 60; + eq(mod(cossack, 'attack', musket, 'Stronger Against the Wounded'), 33, 'and against a wounded one'); // A Nuclear Missile ties up two Uranium. unit(game, 'nuclear_missile', 0, at(2, 2)); eq(strategic(game, game.players[0]).uranium?.used, 2, 'two Uranium');