Skip to content

feature request / starter patch: unit templates, fire ships, academy helper, dodge, quests #80

Description

@dmandelas2-png

hey, first off thank you for making modernbot.

i wanted to share a feature request and a starter patch idea. i was trying to build fire ships, but it looks like the internal grepolis unit id is probably demolition_ship, since the older src/autoTrain.js already includes demolition_ship in the naval unit order.

i know some of these features may need live testing or endpoint adjustments, but hopefully this is useful as a starting point.

features requested

  • quest completer
  • dodge assistant
  • improved unit builder
  • fire ship support using demolition_ship
  • academy helper
  • city builder templates
  • attack planner, preferably manual-confirm instead of fully automatic

city template ideas

  • bireme city
  • fire ship city
  • slinger city
  • light ship city
  • defense city
  • conquest/support city
  • farming/resource city

starter code

this is a starter module for the unit builder/template part.

file:

new/modules/autoUnitBuilder.js

class AutoUnitBuilder extends ModernUtils {
	GROUND_ORDER = ['catapult', 'sword', 'archer', 'hoplite', 'slinger', 'rider', 'chariot'];

	NAVAL_ORDER = [
		'small_transporter',
		'bireme',
		'trireme',
		'attack_ship',
		'big_transporter',
		'demolition_ship',
		'colonize_ship',
	];

	UNIT_ALIASES = {
		fire_ship: 'demolition_ship',
		fire_ships: 'demolition_ship',
		fireship: 'demolition_ship',
		fireships: 'demolition_ship',
		demo_ship: 'demolition_ship',
		demolition_ship: 'demolition_ship',
	};

	REQUIREMENTS = {
		slinger: { research: 'slinger' },
		archer: { research: 'archer' },
		rider: { research: 'rider' },
		chariot: { research: 'chariot' },
		catapult: { research: 'catapult' },

		bireme: { research: 'bireme' },
		trireme: { research: 'trireme' },
		attack_ship: { research: 'attack_ship' },
		big_transporter: { research: 'big_transporter' },
		demolition_ship: { research: 'demolition_ship' },
		colonize_ship: { research: 'colonize_ship' },
	};

	TEMPLATES = {
		bireme_city: {
			label: 'Bireme City',
			units: {
				bireme: 250,
			},
			academy: ['bireme'],
		},

		fire_ship_city: {
			label: 'Fire Ship City',
			units: {
				demolition_ship: 200,
			},
			academy: ['demolition_ship'],
		},

		slinger_city: {
			label: 'Slinger City',
			units: {
				slinger: 3000,
				small_transporter: 32,
			},
			academy: ['slinger'],
		},

		light_ship_city: {
			label: 'Light Ship City',
			units: {
				attack_ship: 250,
			},
			academy: ['attack_ship'],
		},

		defense_city: {
			label: 'Defense City',
			units: {
				sword: 600,
				archer: 600,
				hoplite: 600,
				bireme: 100,
			},
			academy: ['archer', 'bireme'],
		},

		conquest_support: {
			label: 'Conquest / Support',
			units: {
				colonize_ship: 1,
				big_transporter: 20,
				bireme: 100,
			},
			academy: ['colonize_ship', 'big_transporter', 'bireme'],
		},

		farming_city: {
			label: 'Farming / Resource City',
			units: {
				sword: 250,
				archer: 250,
			},
			academy: ['archer'],
		},
	};

	constructor() {
		super();

		this.active = this.loadSettings('unit_builder_active', false);
		this.townTemplates = this.loadSettings('unit_builder_templates', {});
		this.maxOrdersPerKind = this.loadSettings('unit_builder_max_orders', 6);
		this.maxBatchSize = this.loadSettings('unit_builder_max_batch', 50);
	}

	render() {
		const { $container, $title } = this.getTitleElement('Unit Builder', '(click to toggle)');

		this.$container = $container;
		this.$title = $title;

		if (this.active) {
			this.$title.addClass('active');
		}

		this.$title.click(() => this.toggle());

		const town = this.getCurrentTown();
		const townId = this.getTownId(town);
		const currentTemplateId = this.townTemplates[townId] || '';
		const currentTemplate = this.TEMPLATES[currentTemplateId];

		const $body = $('<div>').css({ padding: '8px' });
		const $townInfo = $('<div>').css({ marginBottom: '8px' });

		$townInfo.append(
			$('<div>').html(`<b>${town.getName()}</b> <span style="opacity:.7">[${town.getPoints()} pts]</span>`)
		);

		if (currentTemplate) {
			$townInfo.append($('<div>').text(`template: ${currentTemplate.label}`));
		} else {
			$townInfo.append($('<div>').text('template: none selected'));
		}

		const missing = currentTemplate ? this.getMissingRequirements(town, currentTemplate) : [];

		if (missing.length) {
			$townInfo.append(
				$('<div>')
					.css({ color: '#d88', marginTop: '4px' })
					.text(`missing academy/building requirements: ${missing.join(', ')}`)
			);
		}

		$body.append($townInfo);

		const $buttons = $('<div>').css({
			display: 'flex',
			flexWrap: 'wrap',
			gap: '4px',
			marginBottom: '8px',
		});

		Object.entries(this.TEMPLATES).forEach(([templateId, template]) => {
			const $button = this.getButtonElement(template.label);

			if (templateId === currentTemplateId) {
				$button.addClass('disabled');
			}

			$button.click(() => {
				this.setTemplate(townId, templateId);

				if (uw.HumanMessage?.success) {
					uw.HumanMessage.success(`template set: ${template.label}`);
				} else {
					console.log(`[ModernBot] template set: ${template.label}`);
				}
			});

			$buttons.append($button);
		});

		const $clear = this.getButtonElement('Clear Template');

		$clear.click(() => {
			delete this.townTemplates[townId];
			this.saveSettings('unit_builder_templates', this.townTemplates);

			if (uw.HumanMessage?.success) {
				uw.HumanMessage.success('template cleared');
			} else {
				console.log('[ModernBot] template cleared');
			}
		});

		$buttons.append($clear);
		$body.append($buttons);

		if (currentTemplate) {
			$body.append(this.renderTemplatePreview(town, currentTemplate));
		}

		$container.append($body);

		return $container;
	}

	renderTemplatePreview(town, template) {
		const $table = $('<table>').css({ width: '100%', fontSize: '12px' });

		$table.append(`
			<tr>
				<th style="text-align:left">unit</th>
				<th style="text-align:left">target</th>
				<th style="text-align:left">owned + queued</th>
				<th style="text-align:left">remaining</th>
				<th style="text-align:left">status</th>
			</tr>
		`);

		Object.entries(template.units).forEach(([rawUnit, target]) => {
			const unit = this.normalizeUnit(rawUnit);
			const current = this.getOwnedAndQueuedCount(town, unit);
			const remaining = Math.max(0, target - current);
			const canBuild = this.canBuildUnit(town, unit);
			const status = canBuild.ok ? 'ready' : `missing: ${canBuild.missing.join(', ')}`;

			$table.append(`
				<tr>
					<td>${unit}</td>
					<td>${target}</td>
					<td>${current}</td>
					<td>${remaining}</td>
					<td>${status}</td>
				</tr>
			`);
		});

		return $table;
	}

	toggle() {
		this.active = !this.active;
		this.saveSettings('unit_builder_active', this.active);

		if (this.$title) {
			this.$title.toggleClass('active');
		}
	}

	setTemplate(townId, templateId) {
		this.townTemplates[townId] = templateId;
		this.saveSettings('unit_builder_templates', this.townTemplates);
	}

	async execute() {
		if (!this.active) return false;

		if ($('.botcheck').length || $('#recaptcha_window').length || $('.g-recaptcha').length) {
			return false;
		}

		const townIds = Object.keys(this.townTemplates);

		for (const townId of townIds) {
			const town = this.getTown(townId);

			if (!town) {
				delete this.townTemplates[townId];
				this.saveSettings('unit_builder_templates', this.townTemplates);
				continue;
			}

			const templateId = this.townTemplates[townId];
			const template = this.TEMPLATES[templateId];

			if (!template) continue;

			const didBuild = await this.buildForTown(town, template);

			if (didBuild) {
				return true;
			}
		}

		return false;
	}

	async buildForTown(town, template) {
		const townId = this.getTownId(town);

		for (const [rawUnit, target] of Object.entries(template.units)) {
			const unit = this.normalizeUnit(rawUnit);
			const unitData = this.getUnitData(unit);

			if (!unitData) continue;

			const canBuild = this.canBuildUnit(town, unit);

			if (!canBuild.ok) {
				console.log(`[ModernBot] ${town.getName()}: cannot build ${unit}; missing ${canBuild.missing.join(', ')}`);
				continue;
			}

			const kind = this.getUnitKind(unit);

			if (this.getUnitOrdersCount(town, kind) >= this.maxOrdersPerKind) {
				continue;
			}

			const current = this.getOwnedAndQueuedCount(town, unit);
			const remaining = Math.max(0, target - current);

			if (remaining <= 0) continue;

			const affordable = this.getAffordableCount(town, unit);
			const amount = Math.min(remaining, affordable, this.maxBatchSize);

			if (amount <= 0) continue;

			this.buildPost(townId, unit, amount);

			await this.sleep(300);

			return true;
		}

		return false;
	}

	buildPost(townId, unit, amount) {
		const data = {
			unit_id: unit,
			amount: amount,
			town_id: townId,
		};

		console.log(`[ModernBot] ${uw.ITowns.getTown(townId).getName()}: training ${amount} ${unit}`);

		uw.gpAjax.ajaxPost('building_barracks', 'build', data);
	}

	getCurrentTown() {
		return uw.ITowns.getCurrentTown();
	}

	getTown(townId) {
		return uw.ITowns.getTown(townId) || uw.ITowns.towns[townId];
	}

	getTownId(town) {
		return String(town.getId ? town.getId() : town.id);
	}

	normalizeUnit(unit) {
		return this.UNIT_ALIASES[unit] || unit;
	}

	getUnitData(unit) {
		return uw.GameData?.units?.[unit] || null;
	}

	getUnitKind(unit) {
		if (this.NAVAL_ORDER.includes(unit)) return 'naval';
		return 'ground';
	}

	getTownResearches(town) {
		return town.researches?.().attributes || {};
	}

	getTownBuildings(town) {
		return town.buildings?.().attributes || {};
	}

	getRequirementForUnit(unit) {
		const unitData = this.getUnitData(unit);

		if (unitData?.requirements) {
			return unitData.requirements;
		}

		return this.REQUIREMENTS[unit] || {};
	}

	canBuildUnit(town, unit) {
		const missing = [];
		const requirements = this.getRequirementForUnit(unit);
		const researches = this.getTownResearches(town);
		const buildings = this.getTownBuildings(town);

		if (requirements.research && !researches[requirements.research]) {
			missing.push(requirements.research);
		}

		if (requirements.building) {
			const currentLevel = buildings[requirements.building] || 0;
			const requiredLevel = requirements.level || 1;

			if (currentLevel < requiredLevel) {
				missing.push(`${requirements.building} ${requiredLevel}`);
			}
		}

		return {
			ok: missing.length === 0,
			missing,
		};
	}

	getMissingRequirements(town, template) {
		const missing = new Set();

		Object.keys(template.units).forEach(rawUnit => {
			const unit = this.normalizeUnit(rawUnit);
			const result = this.canBuildUnit(town, unit);

			result.missing.forEach(item => missing.add(item));
		});

		if (template.academy) {
			const researches = this.getTownResearches(town);

			template.academy.forEach(research => {
				if (!researches[research]) {
					missing.add(research);
				}
			});
		}

		return [...missing];
	}

	getUnitOrdersCount(town, kind) {
		const collection = town.getUnitOrdersCollection?.();

		if (!collection) return 0;

		return collection.where({ kind }).length;
	}

	getOwnedAndQueuedCount(town, unit) {
		let count = 0;

		const townUnits = town.units?.() || {};
		const outerUnits = town.unitsOuter?.() || {};

		count += townUnits[unit] || 0;
		count += outerUnits[unit] || 0;

		const collection = town.getUnitOrdersCollection?.();

		if (collection?.models) {
			for (const order of collection.models) {
				if (order.attributes?.unit_type === unit) {
					count += order.attributes.count || 0;
				}
			}
		}

		return count;
	}

	getAffordableCount(town, unit) {
		const unitData = this.getUnitData(unit);

		if (!unitData) return 0;

		const resources = town.resources();
		const townId = this.getTownId(town);

		let discount = 1;

		try {
			if (uw.GeneralModifications?.getUnitBuildResourcesModification) {
				discount = uw.GeneralModifications.getUnitBuildResourcesModification(townId, unitData);
			}
		} catch (error) {
			discount = 1;
		}

		const cost = unitData.resources || {};

		const woodCost = Math.max(1, Math.round((cost.wood || 0) * discount));
		const stoneCost = Math.max(1, Math.round((cost.stone || 0) * discount));
		const ironCost = Math.max(1, Math.round((cost.iron || 0) * discount));
		const popCost = Math.max(1, unitData.population || 1);

		const byWood = Math.floor((resources.wood || 0) / woodCost);
		const byStone = Math.floor((resources.stone || 0) / stoneCost);
		const byIron = Math.floor((resources.iron || 0) / ironCost);
		const byPop = Math.floor((resources.population || 0) / popCost);

		return Math.max(0, Math.min(byWood, byStone, byIron, byPop));
	}
}

build.js change

add this file to the build list:

'modules/autoUnitBuilder.js',

example:

const files = [
	'utils.js',
	'window.js',
	'modules/autoBootcamp.js',
	'modules/autoFarm.js',
	'modules/autoTrade.js',
	'modules/autoUnitBuilder.js',
	'menu.js',
	'index.js',
];

new/index.js wiring idea

inside the constructor:

this.autoUnitBuilder = new AutoUnitBuilder();

then add a menu tab:

{
	title: 'Units',
	id: 'unit_builder',
	render: () => this.autoUnitBuilder.render(),
},

then in the loop after farming:

const hasUnitBuild = await this.autoUnitBuilder.execute();

if (hasUnitBuild) {
	console.log('[ModernBot] unit builder executed');
	this.lastAction = Date.now();
	return;
}

autofarm bug fix idea

in the current newer autofarm build, it looks like fakeSelectAll uses:

town_ids: this.polislist

but execute stores:

this.polis_list = this.generateList();

so this probably needs to become:

town_ids: this.polis_list

other feature ideas

quest completer:

  • scan for completed quests
  • collect finished quest rewards
  • avoid anything that requires choosing risky quest outcomes automatically

dodge assistant:

  • detect incoming attacks
  • show warning
  • recommend safe city
  • require confirmation before moving troops
  • avoid fully automatic dodge by default

academy helper:

  • read current city template
  • show missing academy research
  • optionally suggest what to research next

city builder templates:

  • each city template should include buildings, academy research, and units
  • example templates: bireme city, fire ship city, slinger city, defense city, conquest/support city

attack planner:

  • choose target city
  • choose unit template
  • choose attack/support type
  • show estimated travel time
  • require manual confirmation before sending

thanks again for keeping modernbot going. even if you do not merge this directly, hopefully it is useful as a starting point.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions