Unit orders now gather steel and high-tech over the days before training, exactly as buildings do, and clicking a queued order reveals what it is gathering, its reserved budget and the soldiers it will draw. The trade graph cached a producer-less node list under a stamp that already reflected the producers' stores, so every seeded mine stayed invisible to buyers and rare-good prices settled at a huge multiple of base; the storage nodes are now re-read after production. Money gifts draw the giver's central-bank reserves in the denomination first, buy the rest from the issuing bank at the market rate, convert into the receiver's currency, and cannot buy from a bank their nation is at war with. Pruned the finished design briefs (intelligence, stacks, air movement, politics performance, economy balance, training UI and FIXME) and refreshed the index and cross-references.
307 lines
14 KiB
JavaScript
307 lines
14 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { smallState, cityOf } from "./framework/helpers.js";
|
|
import { key } from "../shared/hex.js";
|
|
import {
|
|
TREATY_PEACE,
|
|
TREATY_ALLIANCE,
|
|
TREATY_FREE_TRADE,
|
|
TREATY_GIVE_CITY,
|
|
TREATY_GIVE_MILITARY,
|
|
TREATY_GIVE_RESOURCES,
|
|
TREATY_GIVE_MONEY,
|
|
TREATY_JOINT_RESEARCH,
|
|
TREATY_SIGNED,
|
|
TREATY_REJECTED,
|
|
} from "../shared/data/treaties.js";
|
|
import { NEWS_TREATY } from "../shared/data/relations.js";
|
|
|
|
// Signs a treaty directly between two nations, the way a UI flow would.
|
|
function sign(state, from, to, type, payload = {}) {
|
|
const before = state.treatyProposals.length;
|
|
if (!state.requestProposeTreaty(from, to, type, payload)) return false;
|
|
const proposal = state.treatyProposals[before];
|
|
return state.requestRespondTreaty(to, proposal.id, true);
|
|
}
|
|
|
|
export class TreatyTest extends TestCase {
|
|
test_a_proposal_waits_for_the_other_side_and_makes_news() {
|
|
const state = smallState();
|
|
this.assertTrue(state.requestProposeTreaty(0, 1, TREATY_PEACE));
|
|
this.assertSize(state.treatyProposals, 1, "the offer waits for an answer");
|
|
this.assertEmpty(state.treaties, "nothing is signed yet");
|
|
const notice = state.news.filter((entry) => entry.type === NEWS_TREATY).at(-1);
|
|
this.assertEqual(notice.stage, "proposed");
|
|
// The same offer is not repeated while it is pending.
|
|
this.assertFalse(state.requestProposeTreaty(0, 1, TREATY_PEACE));
|
|
}
|
|
|
|
test_only_the_named_receiver_may_answer_and_rejection_is_recorded() {
|
|
const state = smallState(["france", "britain", "poland"]);
|
|
state.requestProposeTreaty(0, 1, TREATY_PEACE);
|
|
const proposal = state.treatyProposals[0];
|
|
this.assertFalse(state.requestRespondTreaty(2, proposal.id, true), "a third party cannot sign");
|
|
this.assertFalse(state.requestRespondTreaty(0, proposal.id, true), "nor the proposer");
|
|
this.assertTrue(state.requestRespondTreaty(1, proposal.id, false), "the receiver rejects");
|
|
this.assertEmpty(state.treatyProposals);
|
|
this.assertEmpty(state.treaties);
|
|
const notice = state.news.filter((entry) => entry.type === NEWS_TREATY).at(-1);
|
|
this.assertEqual(notice.stage, TREATY_REJECTED);
|
|
}
|
|
|
|
test_signing_records_an_active_treaty_and_a_signed_notice() {
|
|
const state = smallState();
|
|
this.assertTrue(sign(state, 0, 1, TREATY_ALLIANCE));
|
|
this.assertTrue(state.areAllied(0, 1));
|
|
this.assertTrue(state.hasTreaty(1, 0, TREATY_ALLIANCE), "symmetric lookup");
|
|
this.assertEqual(state.treaties[0].a, 0);
|
|
this.assertEqual(state.treaties[0].b, 1);
|
|
const notice = state.news.filter((entry) => entry.type === NEWS_TREATY).at(-1);
|
|
this.assertEqual(notice.stage, TREATY_SIGNED);
|
|
}
|
|
|
|
test_peace_ends_the_war_and_sends_troops_home() {
|
|
const state = smallState();
|
|
this.assertTrue(state.requestDeclareWar(0, 1));
|
|
// Put an invader deep on the enemy's soil.
|
|
const enemyTile = state._territoryByCiv.get(1)[0];
|
|
const unit = state.units.find((u) => u.civ === 0);
|
|
state._moveUnitTo(unit, { x: enemyTile.x, y: enemyTile.y });
|
|
this.assertTrue(sign(state, 0, 1, TREATY_PEACE));
|
|
this.assertFalse(state.isAtWar(0, 1), "the war is over");
|
|
const city = cityOf(state, 0);
|
|
this.assertEqual(unit.coords.x, city.coords.x, "the invader is back at a home city");
|
|
this.assertEqual(unit.coords.y, city.coords.y);
|
|
}
|
|
|
|
test_an_alliance_drags_allies_into_the_war() {
|
|
const state = smallState(["france", "britain", "poland"]);
|
|
this.assertTrue(sign(state, 1, 2, TREATY_ALLIANCE), "Britain and Poland ally");
|
|
this.assertTrue(state.requestDeclareWar(0, 1), "France attacks Britain");
|
|
this.assertTrue(state.isAtWar(0, 1), "the target is at war");
|
|
this.assertTrue(state.isAtWar(0, 2), "Poland is drawn in");
|
|
this.assertTrue(state.areAllied(1, 2), "the defenders' alliance holds");
|
|
}
|
|
|
|
test_going_to_war_with_an_ally_breaks_the_alliance() {
|
|
const state = smallState();
|
|
this.assertTrue(sign(state, 0, 1, TREATY_ALLIANCE));
|
|
this.assertTrue(state.areAllied(0, 1));
|
|
this.assertTrue(state.requestDeclareWar(0, 1));
|
|
this.assertFalse(state.areAllied(0, 1), "the alliance cannot survive the war");
|
|
this.assertTrue(state.isAtWar(0, 1));
|
|
}
|
|
|
|
test_marching_across_an_ally_does_not_declare_war() {
|
|
const state = smallState(["france", "britain", "poland"]);
|
|
this.assertTrue(sign(state, 0, 2, TREATY_ALLIANCE));
|
|
const unit = state.units.find((u) => u.civ === 0);
|
|
const goal = state.topology
|
|
.neighbours(unit.coords.x, unit.coords.y)
|
|
.find((n) => state._isLand(n) && !state.cityAt(n) && !state.unitAt(n));
|
|
this.assertNotNull(goal, "the fixture has an empty land neighbour");
|
|
state.territory.set(`${goal.x},${goal.y}`, 2);
|
|
this.assertTrue(state.requestMove(unit.id, goal), "the march into allied land is accepted");
|
|
this.assertFalse(state.isAtWar(0, 2), "crossing an ally declares no war");
|
|
this.assertTrue(state.areAllied(0, 2), "and leaves the alliance intact");
|
|
}
|
|
|
|
test_renewed_war_breaks_a_peace_treaty() {
|
|
const state = smallState();
|
|
this.assertTrue(state.requestDeclareWar(0, 1));
|
|
this.assertTrue(sign(state, 0, 1, TREATY_PEACE));
|
|
this.assertFalse(state.isAtWar(0, 1), "the peace holds");
|
|
this.assertTrue(state.hasTreaty(0, 1, TREATY_PEACE));
|
|
this.assertTrue(state.requestDeclareWar(0, 1), "going back to war");
|
|
this.assertFalse(state.hasTreaty(0, 1, TREATY_PEACE), "the peace treaty is broken");
|
|
this.assertTrue(state.isAtWar(0, 1));
|
|
}
|
|
|
|
test_free_trade_partners_are_exempt_from_tariffs() {
|
|
const state = smallState();
|
|
this.assertTrue(sign(state, 0, 1, TREATY_FREE_TRADE));
|
|
this.assertTrue(state.freeTradePartners(0).has(1));
|
|
this.assertTrue(state._tariffExempt(0, 1));
|
|
this.assertTrue(state._tariffExempt(1, 0));
|
|
this.assertFalse(state._tariffExempt(0, 2));
|
|
}
|
|
|
|
test_giving_money_moves_the_sum_between_treasuries() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 1_000_000);
|
|
state.budgets.set(1, 0);
|
|
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 400_000 }));
|
|
this.assertApprox(state.getBudget(0), 600_000, 1e-6);
|
|
this.assertApprox(state.getBudget(1), 400_000, 1e-6);
|
|
this.assertFalse(
|
|
state.requestProposeTreaty(0, 1, TREATY_GIVE_MONEY, { amount: 0 }),
|
|
"a zero gift is refused"
|
|
);
|
|
}
|
|
|
|
test_a_gift_is_converted_into_the_receivers_currency_at_the_market_rate() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 1_000_000);
|
|
state.budgets.set(1, 0);
|
|
// The receiver's currency is worth half, so its money is twice as dear.
|
|
state.setCurrencyValue(1, 0.5);
|
|
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 400_000 }));
|
|
// 400,000 of the receiver's currency is 200,000 of global value: the giver
|
|
// pays that in its own (par) money and the receiver is credited it back as
|
|
// 400,000 of its dearer currency.
|
|
this.assertApprox(state.getBudget(0), 800_000, 1e-6);
|
|
this.assertApprox(state.getBudget(1), 400_000, 1e-6);
|
|
}
|
|
|
|
test_a_gift_spends_the_banks_reserves_before_buying_the_currency() {
|
|
const state = smallState();
|
|
const code = state.currencyOf(1).code;
|
|
state.budgets.set(0, 1_000_000);
|
|
state.budgets.set(1, 0);
|
|
state._addReserve(0, code, 300);
|
|
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 1_000 }));
|
|
this.assertApprox(
|
|
state.getCentralBankReserves(0).get(code), 0, 1e-6,
|
|
"the bank's reserves are spent first"
|
|
);
|
|
this.assertApprox(state.getBudget(1), 1_000, 1e-6, "the receiver gets the full gift");
|
|
this.assertApprox(state.getBudget(0), 999_000, 1e-6, "the giver pays the full value");
|
|
}
|
|
|
|
test_a_gift_may_be_denominated_in_a_third_currency() {
|
|
const state = smallState(["france", "britain", "poland"]);
|
|
state.budgets.set(0, 1_000_000);
|
|
state.budgets.set(1, 0);
|
|
const code = state.currencyOf(2).code;
|
|
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 500_000, currency: code }));
|
|
this.assertApprox(state.getBudget(1), 500_000, 1e-6);
|
|
this.assertFalse(
|
|
state.requestProposeTreaty(0, 1, TREATY_GIVE_MONEY, { amount: 1, currency: "ZZZ" }),
|
|
"an unknown currency is refused"
|
|
);
|
|
}
|
|
|
|
test_a_war_with_the_issuer_blocks_buying_and_limits_the_gift() {
|
|
const state = smallState(["france", "britain", "poland"]);
|
|
const code = state.currencyOf(2).code;
|
|
state.budgets.set(0, 1_000_000);
|
|
state.budgets.set(1, 0);
|
|
state.requestDeclareWar(0, 2);
|
|
// At war with the issuing bank and holding none of its money, nothing can be
|
|
// bought, so the gift moves nothing at all.
|
|
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 1_000, currency: code }));
|
|
this.assertApprox(state.getBudget(0), 1_000_000, 1e-6, "the giver paid nothing");
|
|
this.assertApprox(state.getBudget(1), 0, 1e-6, "the receiver got nothing");
|
|
}
|
|
|
|
test_a_blocked_gift_still_moves_the_reserves_already_held() {
|
|
const state = smallState(["france", "britain", "poland"]);
|
|
const code = state.currencyOf(2).code;
|
|
state.budgets.set(0, 1_000_000);
|
|
state.budgets.set(1, 0);
|
|
state.requestDeclareWar(0, 2);
|
|
state._addReserve(0, code, 250);
|
|
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 1_000, currency: code }));
|
|
this.assertApprox(
|
|
state.getCentralBankReserves(0).get(code), 0, 1e-6,
|
|
"the reserves in hand are still handed over"
|
|
);
|
|
this.assertApprox(state.getBudget(1), 250, 1e-6, "the receiver gets only what was held");
|
|
}
|
|
|
|
test_giving_military_transfers_units_but_never_infantry() {
|
|
const state = smallState();
|
|
const infantry = state.units.find((u) => u.civ === 0 && state.unitProto(u).infantry);
|
|
this.assertNotNull(infantry, "the fixture has an infantry unit");
|
|
this.assertFalse(
|
|
state.requestProposeTreaty(0, 1, TREATY_GIVE_MILITARY, { units: [infantry.id] }),
|
|
"infantry cannot be gifted"
|
|
);
|
|
const mobile = state.units.find((u) => u.civ === 0 && !state.unitProto(u).infantry);
|
|
// The fixture standard army is all infantry, so raise a gun to gift.
|
|
if (!mobile) {
|
|
const artillery = state.protoUnits.findIndex((p) => p.id === "artillery");
|
|
state._spawnUnit(cityOf(state, 0).coords, 0, state.protoUnits[artillery]);
|
|
}
|
|
const gift = state.units.find((u) => u.civ === 0 && !state.unitProto(u).infantry);
|
|
this.assertNotNull(gift, "a non-infantry unit is available");
|
|
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MILITARY, { units: [gift.id] }));
|
|
this.assertEqual(state.findUnit(gift.id).civ, 1, "the unit changed hands");
|
|
}
|
|
|
|
test_giving_a_city_transfers_it_and_its_land() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
const cityKey = key(city.coords.x, city.coords.y);
|
|
this.assertFalse(
|
|
state.requestProposeTreaty(0, 1, TREATY_GIVE_CITY, { city: 99999 }),
|
|
"an unknown city is refused"
|
|
);
|
|
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_CITY, { city: city.id }));
|
|
this.assertEqual(state.findCity(city.id).civ, 1, "the city changed hands");
|
|
this.assertEqual(state.territory.get(cityKey), 1, "its tile went with it");
|
|
}
|
|
|
|
test_giving_resources_moves_the_basket_to_the_receiver() {
|
|
const state = smallState();
|
|
const giver = cityOf(state, 0);
|
|
const receiver = cityOf(state, 1);
|
|
const giverStore = state.getCityResourceStock(giver);
|
|
giverStore.steel = 1000;
|
|
giverStore.food = 500;
|
|
const receiverStore = state.getCityResourceStock(receiver);
|
|
const receiverSteel = receiverStore.steel || 0;
|
|
const receiverFood = receiverStore.food || 0;
|
|
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_RESOURCES, {
|
|
resources: { steel: 300, food: 200 },
|
|
}));
|
|
this.assertApprox(giverStore.steel, 700, 1e-6, "the steel left the giver");
|
|
this.assertApprox(giverStore.food, 300, 1e-6, "and so did the food");
|
|
this.assertApprox(receiverStore.steel, receiverSteel + 300, 1e-6, "the receiver got the steel");
|
|
this.assertApprox(receiverStore.food, receiverFood + 200, 1e-6, "and the food");
|
|
}
|
|
|
|
test_joint_research_shares_points_but_not_military_ones() {
|
|
const state = smallState();
|
|
this.assertTrue(sign(state, 0, 1, TREATY_JOINT_RESEARCH));
|
|
const civil = state.technologies.findIndex((t) => t.id === "scientific_method");
|
|
const military = state.technologies.findIndex((t) => t.id === "body_armour");
|
|
state.focusTechnology.set(0, civil);
|
|
state.focusTechnology.set(1, civil);
|
|
state._addResearch(0, 5);
|
|
this.assertApprox(state.getTechnologyProgress(1, civil), 5, 1e-6, "the partner shares the points");
|
|
// A military technology is not shared.
|
|
state.focusTechnology.set(0, military);
|
|
state.focusTechnology.set(1, civil);
|
|
const before = state.getTechnologyProgress(1, civil);
|
|
state._addResearch(0, 7);
|
|
this.assertApprox(
|
|
state.getTechnologyProgress(1, civil),
|
|
before,
|
|
1e-6,
|
|
"military research stays private"
|
|
);
|
|
}
|
|
|
|
test_ending_a_treaty_moves_it_to_the_history() {
|
|
const state = smallState();
|
|
this.assertTrue(sign(state, 0, 1, TREATY_ALLIANCE));
|
|
const treaty = state.treaties[0];
|
|
this.assertTrue(state.requestCancelTreaty(1, treaty.id), "either party may end it");
|
|
this.assertEmpty(state.treaties);
|
|
this.assertSize(state.treatyHistory, 1);
|
|
this.assertEqual(state.treatyHistory[0].reason, "broken");
|
|
this.assertFalse(state.areAllied(0, 1));
|
|
}
|
|
|
|
test_the_snapshot_ships_treaties_proposals_and_history() {
|
|
const state = smallState();
|
|
state.requestProposeTreaty(0, 1, TREATY_PEACE);
|
|
const snapshot = state.snapshot(0);
|
|
this.assertSize(snapshot.treatyProposals, 1);
|
|
this.assertEqual(snapshot.treatyProposals[0].from, 0);
|
|
this.assertEqual(snapshot.treatyProposals[0].type, TREATY_PEACE);
|
|
this.assertEmpty(snapshot.treaties);
|
|
this.assertEmpty(snapshot.treatyHistory);
|
|
}
|
|
}
|