feat(civ): AI-initiated diplomacy with frustration escalation

Introduce active diplomacy where rival leaders approach the player with
requests, demands, trades, and gifts rather than passively waiting.

New CivilizationDiplomacy.js module handles:
- 9 request kinds (joinWar, breakTreaty, borderUltimatum, demandGold,
  demandTech, techTrade, ceasefire, peace, alliance) with initiative
  tiers so urgent calls to arms aren't drowned out by filler offers
- Cooldowns per (leader, target, kind) plus a per-leader gap so a
  leader can't spam different requests to bypass rate limits
- Frustration grudge (0-100) raised by refusals, decaying each turn
  and dragging attitude toward hostility through the ordinary war path
- Escalation: frustrated leaders demand compensation, then renounce
  treaties at breaking point, with ordinary hostility finishing the job
- Border ultimatum promises checked when pledges expire (double penalty
  for broken word)
- Unprompted gold/tech gifts from leaders with high attitude
- Staleness validation so requests the world invalidated are dropped
- AI-to-AI requests resolve immediately through the same pipeline

UI changes:
- AI requests queued on state.pendingRequests, presented via audience
  popup at the start of the human turn (one per turn, configurable)
- Diplomacy screen shows ACCEPT/REFUSE with consequence hints until
  answered; unanswered requests lapse with half-weight penalties
- New event types: treatyRenounced, pledgeBroken/pledgeKept, aiGift
- Leader dialogue for demands, refusals, gifts, and renouncements
- Tetris Attack menu gets a title art backdrop

Data/infra:
- diplomacyRequests tuning in civilization-rules.json (thresholds,
  cooldowns, attitude weights, per-request config)
- Frustration, requestCooldown, lastRequestTurn, pledges fields on
  civ state — all backward-compatible with old saves
- Full verification suite covering eligibility, resolution, cooldowns,
  frustration arc, serialization, and fuzz testing
This commit is contained in:
Brian Fertig 2026-07-23 16:26:15 -06:00
parent 2880eeab2a
commit 4fcae92e59
12 changed files with 1433 additions and 57 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -296,6 +296,39 @@
"Starfall", "Tidewater", "Underhill", "Violetgate", "Wheatfield", "Wolfden", "Yarrow", "Zenith" "Starfall", "Tidewater", "Underhill", "Violetgate", "Wheatfield", "Wolfden", "Yarrow", "Zenith"
], ],
"_diplomacyRequestsReadme": [
"AI-initiated diplomacy. maxPerTurn caps how many audiences the player is asked for in",
"one turn; perLeaderGap is the minimum turns between any two requests from the same",
"leader; each kind additionally has its own cooldown so a leader cannot repeat the same",
"ask. Refusals add refuseFrustration (0-100, decays frustrationDecay/turn); frustration",
"drags the leader's attitude baseline down by frustrationAttitudeWeight per point, which",
"feeds the normal war logic. At frustrationDemandThreshold the leader starts demanding",
"gold/tech as compensation; at frustrationBreakThreshold it renounces treaties."
],
"diplomacyRequests": {
"maxPerTurn": 1,
"perLeaderGap": 10,
"goodStandingAttitude": 25,
"giftAttitude": 70,
"giftCooldown": 30,
"lapseTurns": 3,
"frustrationDecay": 0.5,
"frustrationDemandThreshold": 30,
"frustrationBreakThreshold": 50,
"frustrationAttitudeWeight": 0.5,
"kinds": [
{ "id": "joinWar", "cooldown": 25, "refuseFrustration": 25, "refuseAttitude": -12, "acceptAttitude": 30, "acceptFrustration": -40 },
{ "id": "breakTreaty", "cooldown": 20, "refuseFrustration": 15, "refuseAttitude": -6, "acceptAttitude": 18, "acceptFrustration": -25 },
{ "id": "borderUltimatum", "cooldown": 18, "refuseFrustration": 15, "refuseAttitude": -10, "acceptAttitude": 10, "acceptFrustration": -20 },
{ "id": "demandGold", "cooldown": 20, "refuseFrustration": 20, "refuseAttitude": -15, "acceptAttitude": 15, "acceptFrustration": -100 },
{ "id": "demandTech", "cooldown": 20, "refuseFrustration": 20, "refuseAttitude": -15, "acceptAttitude": 15, "acceptFrustration": -100 },
{ "id": "techTrade", "cooldown": 12, "refuseFrustration": 0, "refuseAttitude": -2, "acceptAttitude": 5, "acceptFrustration": 0 },
{ "id": "ceasefire", "cooldown": 12, "refuseFrustration": 0, "refuseAttitude": -3, "acceptAttitude": 0, "acceptFrustration": 0 },
{ "id": "peace", "cooldown": 12, "refuseFrustration": 4, "refuseAttitude": -3, "acceptAttitude": 0, "acceptFrustration": 0 },
{ "id": "alliance", "cooldown": 15, "refuseFrustration": 6, "refuseAttitude": -3, "acceptAttitude": 0, "acceptFrustration": 0 }
]
},
"yearCurve": [ "yearCurve": [
{ "until": -1000, "step": 50 }, { "until": -1000, "step": 50 },
{ "until": 1, "step": 25 }, { "until": 1, "step": 25 },

View File

@ -223,6 +223,7 @@ export const MANIFEST = {
tetrisattack: [ tetrisattack: [
{ type: 'json', key: 'tetrisattack', path: 'data/tetrisattack.json' }, { type: 'json', key: 'tetrisattack', path: 'data/tetrisattack.json' },
{ type: 'json', key: 'tetrisattack-puzzles', path: 'data/tetrisattack-puzzles.json' }, { type: 'json', key: 'tetrisattack-puzzles', path: 'data/tetrisattack-puzzles.json' },
image('tetrisattack-menu-bg', 'assets/images/tetrisattack/main-menu.png'),
(scene) => sheetsFrom(scene, 'tetrisattack-artwork', ['panelSheet', 'characterSheet']), (scene) => sheetsFrom(scene, 'tetrisattack-artwork', ['panelSheet', 'characterSheet']),
// Stage Clear backgrounds (background-{hero}-r{stage}.png, 6 friends × 5 // Stage Clear backgrounds (background-{hero}-r{stage}.png, 6 friends × 5
// stages) and character voice clips (assets/fx/tetrisattack/{hero}-*.mp3) // stages) and character voice clips (assets/fx/tetrisattack/{hero}-*.mp3)

View File

@ -2,9 +2,10 @@
// //
// runAITurn(rules, state, civIdx) plays one civ's whole turn: strategy pick, // runAITurn(rules, state, civIdx) plays one civ's whole turn: strategy pick,
// diplomacy, research, city builds, then unit orders. respondToProposal // diplomacy, research, city builds, then unit orders. respondToProposal
// answers human (or other-AI) diplomacy. AI-initiated proposals toward the // answers human (or other-AI) diplomacy. AI-initiated approaches toward the
// human are queued on state.events as { type: 'aiProposal', keep: true } for // human (treaty proposals, favours, demands) are queued on
// the scene to present at the start of the human turn. // state.pendingRequests for the scene to present as an audience at the start
// of the human turn — see CivilizationDiplomacy.js.
import { import {
rand, randInt, cheb, tileIndex, inBounds, terrainAt, cityAt, unitsAt, rand, randInt, cheb, tileIndex, inBounds, terrainAt, cityAt, unitsAt,
@ -12,10 +13,14 @@ import {
availableBuildings, canFoundCity, foundCity, setBuild, buyCost, buyBuild, availableBuildings, canFoundCity, foundCity, setBuild, buyCost, buyBuild,
tryMove, disembark, findPath, startWork, canWork, canEstablishRoute, tryMove, disembark, findPath, startWork, canWork, canEstablishRoute,
establishTradeRoute, resolveAttack, attackerStrength, defenderStrength, establishTradeRoute, resolveAttack, attackerStrength, defenderStrength,
pickDefender, launchSpaceship, canPropose, applyTreaty, declareWar, pickDefender, launchSpaceship, canPropose, declareWar,
exchangeTechs, civPower, cityYields, buildCost, exchangeTechs, civPower, cityYields, buildCost,
setResearch, startRevolution, setResearch, startRevolution,
} from './CivilizationLogic.js'; } from './CivilizationLogic.js';
import {
considerRequest, considerGift, makeTreatyRequest, resolveRequest, aiWouldAccept,
escalateFrustration, checkPledges, pickFairTrade, setCooldown,
} from './CivilizationDiplomacy.js';
import { siteQuality } from './CivilizationWorldGen.js'; import { siteQuality } from './CivilizationWorldGen.js';
const MAX_UNIT_STEPS = 40; // per unit per turn, guards against loops const MAX_UNIT_STEPS = 40; // per unit per turn, guards against loops
@ -78,10 +83,15 @@ export function computeStrategy(rules, state, civIdx) {
function doDiplomacy(rules, state, civIdx, strategy) { function doDiplomacy(rules, state, civIdx, strategy) {
const civ = state.civs[civIdx]; const civ = state.civs[civIdx];
checkPledges(rules, state, civIdx);
for (const other of state.civs) { for (const other of state.civs) {
if (other.id === civIdx || !other.alive) continue; if (other.id === civIdx || !other.alive) continue;
if (civ.relations[other.id] === 'nocontact') continue;
// A leader worn down by refused requests tears up its treaties first; if
// the grudge keeps climbing, the hostility checks below finish the job.
// This can change the relation, so read it afterwards.
escalateFrustration(rules, state, civIdx, other.id);
const rel = civ.relations[other.id]; const rel = civ.relations[other.id];
if (rel === 'nocontact') continue;
const attitude = civ.attitude[other.id] ?? 0; const attitude = civ.attitude[other.id] ?? 0;
const powerRatio = strategy.myPower / Math.max(1, civPower(rules, state, other.id)); const powerRatio = strategy.myPower / Math.max(1, civPower(rules, state, other.id));
@ -115,29 +125,51 @@ function doDiplomacy(rules, state, civIdx, strategy) {
&& sharedEnemy(state, civIdx, other.id)) { && sharedEnemy(state, civIdx, other.id)) {
proposeOrQueue(rules, state, civIdx, other.id, 'alliance'); proposeOrQueue(rules, state, civIdx, other.id, 'alliance');
} }
// Fair tech trades with non-hostile AIs (human trades happen in their UI). // Fair tech trades with non-hostile AIs. The human gets asked instead, via
// a techTrade request below, so the offer goes through the audience UI.
if (!other.human && attitude > 0 && rand(state) < 0.25) { if (!other.human && attitude > 0 && rand(state) < 0.25) {
tryFairExchange(rules, state, civIdx, other.id); tryFairExchange(rules, state, civIdx, other.id);
} }
// Leader-initiated approaches: favours, ultimatums, demands, gifts.
if (considerGift(rules, state, civIdx, other.id)) continue;
const req = considerRequest(rules, state, civIdx, other.id, strategy);
if (req) issueRequest(rules, state, req);
} }
} }
// Routes a request at its recipient: the human sees it as an audience at the
// start of their turn, another AI answers it on the spot. Cooldowns are set
// when the request is ISSUED (inside resolveRequest for AI targets, here for
// human ones) so an unanswered request can't be re-asked next turn.
function issueRequest(rules, state, req) {
if (state.civs[req.to].human) {
state.pendingRequests ??= [];
// One audience is drained per human turn; a hard cap stops a crowded world
// from queueing up a backlog the player can never work through.
if (state.pendingRequests.length >= 4) return;
if (state.pendingRequests.some((p) => p.from === req.from && p.kind === req.kind)) return;
setCooldown(rules, state, req.from, req.to, req.kind);
state.pendingRequests.push(req);
return;
}
// Treaty kinds keep their existing AI answer; the rest use aiWouldAccept.
const TREATY = ['ceasefire', 'peace', 'alliance'];
const accepted = TREATY.includes(req.kind)
? respondToProposal(rules, state, req.to, req.from, req.kind)
: aiWouldAccept(rules, state, req);
resolveRequest(rules, state, req, accepted);
}
function sharedEnemy(state, a, b) { function sharedEnemy(state, a, b) {
return state.civs.some((c) => c.alive && c.id !== a && c.id !== b return state.civs.some((c) => c.alive && c.id !== a && c.id !== b
&& state.civs[a].relations[c.id] === 'war' && state.civs[b].relations[c.id] === 'war'); && state.civs[a].relations[c.id] === 'war' && state.civs[b].relations[c.id] === 'war');
} }
// Treaty proposals go through the same request pipeline as favours and demands
// so they share the audience UI, the cooldowns and the per-leader gap.
function proposeOrQueue(rules, state, fromIdx, toIdx, kind) { function proposeOrQueue(rules, state, fromIdx, toIdx, kind) {
const target = state.civs[toIdx]; const req = makeTreatyRequest(rules, state, fromIdx, toIdx, kind);
if (target.human) { if (req) issueRequest(rules, state, req);
if (!state.events.some((e) => e.type === 'aiProposal' && e.from === fromIdx && e.kind === kind)) {
state.events.push({ type: 'aiProposal', from: fromIdx, to: toIdx, kind, keep: true });
}
return;
}
if (respondToProposal(rules, state, toIdx, fromIdx, kind)) {
applyTreaty(state, fromIdx, toIdx, kind);
}
} }
export function respondToProposal(rules, state, aiIdx, fromIdx, kind, payload = {}) { export function respondToProposal(rules, state, aiIdx, fromIdx, kind, payload = {}) {
@ -168,18 +200,8 @@ export function respondToProposal(rules, state, aiIdx, fromIdx, kind, payload =
} }
function tryFairExchange(rules, state, a, b) { function tryFairExchange(rules, state, a, b) {
const civA = state.civs[a]; const trade = pickFairTrade(rules, state, a, b);
const civB = state.civs[b]; if (trade) exchangeTechs(rules, state, a, b, trade.giveId, trade.getId);
const aOffers = Object.keys(civA.known).filter((t) => !civB.known[t]);
const bOffers = Object.keys(civB.known).filter((t) => !civA.known[t]);
if (!aOffers.length || !bOffers.length) return;
aOffers.sort((x, y) => (rules.techRank[x] ?? 0) - (rules.techRank[y] ?? 0));
bOffers.sort((x, y) => (rules.techRank[x] ?? 0) - (rules.techRank[y] ?? 0));
const give = aOffers[0];
const get = bOffers[0];
if (Math.abs((rules.techRank[give] ?? 0) - (rules.techRank[get] ?? 0)) <= 1) {
exchangeTechs(rules, state, a, b, give, get);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -4,9 +4,15 @@
// {you} — the player's leader name (the person being spoken to) // {you} — the player's leader name (the person being spoken to)
// {me} — the opponent leader's name // {me} — the opponent leader's name
// {gold} — gold amount, {tech}/{give}/{get} — technology names. // {gold} — gold amount, {tech}/{give}/{get} — technology names.
// AI-initiated requests add:
// {target} — the third civ a request is about (join-my-war, break-your-treaty)
// {city} — the city a border ultimatum is about.
// Resolved text (not templates) is what gets stored in state.chatLog. // Resolved text (not templates) is what gets stored in state.chatLog.
export function pickLine(pool, vars = {}) { export function pickLine(pool, vars = {}) {
// An empty/missing pool yields an empty line rather than throwing — a
// conversation with a gap in it beats a crashed diplomacy screen.
if (!pool?.length) return '';
const t = pool[Math.floor(Math.random() * pool.length)]; const t = pool[Math.floor(Math.random() * pool.length)];
return t.replace(/\{(\w+)\}/g, (_, k) => (vars[k] !== undefined ? vars[k] : `{${k}}`)); return t.replace(/\{(\w+)\}/g, (_, k) => (vars[k] !== undefined ? vars[k] : `{${k}}`));
} }
@ -194,3 +200,202 @@ export const REPLIES = {
'My people will put {tech} to good use. Thank you.', 'My people will put {tech} to good use. Thank you.',
], ],
}; };
// ---------------------------------------------------------------------------
// AI-initiated diplomacy (see CivilizationDiplomacy.js). REQUESTS is what the
// leader says when they summon the player; REQUEST_REPLIES is how they take
// the answer. Keys match the request kind ids.
export const REQUESTS = {
joinWar: [
'I did not come for pleasantries, {you}. {target} makes war on me, and I am calling in the friendship between us. Draw your sword alongside mine.',
'Hear me, {you}: my soldiers bleed against {target} while you enjoy the quiet. Friends do not watch from the hillside. Declare war on {target}.',
'The war with {target} goes hard, {you}. I ask you now, plainly — join me against them, and let history record who stood by me.',
],
breakTreaty: [
'{target} is my enemy, {you}, and yet you sit at their table. Renounce your treaty with them. I will not ask twice politely.',
'It galls me to see your banners beside {target}\'s, {you}. Break with them. A friend of my enemy is a stone in my shoe.',
'You call me friend while treating with {target}? Choose, {you}. Tear up that treaty.',
],
borderUltimatum: [
'Your soldiers are camped within sight of {city}, {you}. My people do not sleep well. Withdraw them.',
'Explain the army at the gates of {city}, {you} — or better, remove it. I would rather not guess at your intentions.',
'There is a word for troops massed on a neighbour\'s border, {you}, and it is not friendship. Pull them back from {city}.',
],
demandGold: [
'I have asked you for much and received nothing, {you}. So let us be simple: {gold} gold, and the slate is clean.',
'My patience has a price now, {you}. {gold} gold from your treasury, and I will forget how often you have refused me.',
'You have spent my goodwill freely. Restock it — {gold} gold, and we need not speak of grievances again.',
],
demandTech: [
'Words have failed between us, {you}. Give my scholars {tech} and I will consider the debt paid.',
'You have refused me too often to refuse me lightly again. Hand over {tech}, and my temper cools.',
'One thing will settle this, {you}: the secrets of {tech}. Give them freely, or I will find another way to be compensated.',
],
techTrade: [
'A proposal, {you} — my {give} for your {get}. Knowledge kept in one place rots.',
'My scholars covet your {get}, and yours would surely welcome {give}. Shall we trade, {you}?',
'An even bargain, {you}: {give} for {get}. Say yes and both our peoples wake up wiser.',
],
ceasefire: [
'Enough, {you}. This war has cost us both more than it can ever return. I propose a cease-fire.',
'I will say it first, since one of us must: let the guns fall silent. A cease-fire, {you}.',
'My generals will call me weak for this, {you}. Let us stop the killing — agree to a cease-fire.',
],
peace: [
'The silence between our armies has held, {you}. Let us make it permanent — a formal peace.',
'I have come to offer peace, {you}. Not a pause, not a truce. Peace.',
'Our peoples have bled enough for one generation, {you}. Sign a peace treaty with me.',
],
alliance: [
'We have common enemies and no quarrel with each other, {you}. That is rarer than gold. Let us make it an alliance.',
'I would rather have you at my side than merely out of my way, {you}. An alliance — what say you?',
'Peace is well enough, {you}, but an alliance would make us both untouchable. Join banners with me.',
],
};
export const REQUEST_REPLIES = {
joinWar: {
accept: [
'Then we march together! {target} will learn what it costs to make an enemy of us both.',
'You are a true friend, {you}. My generals will drink your health tonight.',
'Good. Let {target} count their enemies again, and despair.',
],
refuse: [
'So. You will watch me bleed and call it neutrality. I will remember this, {you}.',
'No? Then do not call yourself my friend again. I have noted your answer.',
'Very well. Enjoy your peace, {you} — I will enjoy remembering who bought it.',
],
},
breakTreaty: {
accept: [
'Good. {target} will feel the loss, and I will feel the gain. You have chosen well.',
'Then it is settled. I am glad you know who your friends are, {you}.',
'Wise. Let {target} stand alone and think on their conduct.',
],
refuse: [
'You keep faith with {target} over me. That tells me everything, {you}.',
'Refused. Then I shall count you among their friends, and treat you accordingly.',
'As you wish. But do not expect me to forget where your loyalties lie.',
],
},
borderUltimatum: {
accept: [
'Then see that they move, {you}. I will be watching the fields outside {city}.',
'Your word, given plainly. Keep it and we remain friends.',
'Good. My people will sleep easier — provided those banners are gone.',
],
refuse: [
'You refuse even this? Then your army outside {city} is an answer in itself.',
'Noted, {you}. When the horns sound, do not pretend surprise.',
'So the soldiers stay. Very well. I know what that means.',
],
},
demandGold: {
accept: [
'Heavy, but honest. The debt is settled, {you} — see that it does not grow again.',
'Gold speaks where your promises did not. Very well. We begin afresh.',
'Accepted. My treasury is fuller and my temper cooler. Do not test it twice.',
],
refuse: [
'Nothing, then. Not even coin. You have run out of ways to disappoint me, {you}.',
'You refuse even to pay for your refusals. Then I shall take satisfaction elsewhere.',
'So be it. But grievances left unpaid have a way of collecting interest.',
],
},
demandTech: {
accept: [
'My scholars have {tech} and my anger has cooled. Consider us square, {you}.',
'Knowledge for grievance — a fair exchange. The matter is closed.',
'Accepted. You have bought back some of my patience, {you}.',
],
refuse: [
'You hoard {tech} and my goodwill both. Neither will last forever, {you}.',
'Refused again. There is a limit, and you are past it.',
'Then keep your secrets. I will find my own compensation in time.',
],
},
techTrade: {
accept: [
'Done. Our scholars will meet before the week is out.',
'A fair trade honestly made — the rarest thing in this world.',
'Agreed, {you}. May we both profit from it.',
],
refuse: [
'A pity. Knowledge left idle helps no one.',
'As you wish. The offer stands, should you reconsider.',
'No? Then my scholars will manage without yours.',
],
},
ceasefire: {
accept: [
'Then it is done. Let the guns cool, {you}.',
'Good. My people will bless us both for this.',
'Agreed. The killing stops today.',
],
refuse: [
'You would rather keep fighting. Then fight, {you}.',
'Refused. I offered you an open hand and you spat in it.',
'So the war goes on. On your head be it.',
],
},
peace: {
accept: [
'Peace, then, and let it outlast us both.',
'It is signed. Our peoples may finally rest.',
'Good. I did not want to spend my reign at war with you, {you}.',
],
refuse: [
'You refuse peace itself. That is a rare kind of stubbornness, {you}.',
'Then we remain as we are — wary, armed, and waiting.',
'Refused. I will not offer again soon.',
],
},
alliance: {
accept: [
'Allies! Let our enemies lose sleep tonight.',
'Then it is sworn. Your enemies are mine, {you}.',
'Excellent. Together we shall shape the age.',
],
refuse: [
'You will take my peace but not my hand. Noted, {you}.',
'A shame. I do not extend that offer often.',
'Refused. Then we remain neighbours, and nothing more.',
],
},
};
// The player's side of an audience. Deliberately generic — these have to read
// naturally after any of the requests above.
export const REQUEST_ANSWERS = {
accept: [
'Very well, {me}. It shall be done.',
'You have my word on it. Consider the matter settled.',
'Agreed. I will see to it personally.',
],
refuse: [
'No, {me}. I will not do this.',
'You ask too much. My answer is no.',
'I must refuse. My own people come first.',
],
};
// A leader whose grudge has finally boiled over and torn up the treaty.
export const RENOUNCE = [
'I have asked, and asked, and been refused. Our treaty is worth nothing — so let it be nothing. It is ended, {you}.',
'You have taken my friendship for granted once too often, {you}. Consider our agreement void.',
'No more. Whatever we signed, {you}, I renounce it here and now.',
];
// Unprompted gifts from a leader who genuinely likes the player.
export const GIFT_GOLD = [
'Take this, {you} — {gold} gold from my own treasury. No conditions, no debt. Friendship should be paid forward.',
'My caravans carry {gold} gold to your capital, {you}. Call it a gift between friends.',
'You have been a good neighbour, {you}. Accept {gold} gold, and think well of me.',
];
export const GIFT_TECH = [
'My scholars are on their way to you with the secrets of {tech}, {you}. Freely given.',
'Knowledge hoarded is knowledge wasted. Take {tech}, {you}, with my compliments.',
'I have sent you {tech}, {you}. A gift — I ask nothing for it.',
];

View File

@ -0,0 +1,491 @@
// Civilization — AI-initiated diplomacy. Headless (no Phaser), shared by the
// AI controller and the scene.
//
// Rival leaders don't just answer the player any more: they come asking for
// favours ("join my war against X"), making demands, offering trades and
// sending gifts. Two mechanisms keep that from becoming noise or a doormat
// simulator:
//
// cooldowns — per (leader, target, kind), plus a per-leader gap, so nobody
// asks for the same thing twice in a row. Set when the request
// is ISSUED, not when it's answered, so an ignored request
// can't spam either.
// frustration — a 0-100 grudge (CivilizationLogic bumpFrustration) raised by
// refusals. It decays each turn and drags the leader's attitude
// baseline down while it lasts, so a player who keeps saying no
// slides toward war through the ORDINARY hostility path in
// CivilizationAI.doDiplomacy. Past frustrationDemandThreshold
// the leader starts demanding gold/tech as compensation; past
// frustrationBreakThreshold it renounces its treaties.
//
// All tuning lives in data/civilization-rules.json `diplomacyRequests`
// (compiled to rules.diplomacy + rules.requestKinds).
//
// A request is plain data: { kind, from, to, target?, cityId?, gold?, techId?,
// giveId?, getId?, turn }. Requests aimed at the human are parked on
// state.pendingRequests for the scene to present; AI-to-AI ones resolve
// immediately through the same resolveRequest(), so the two paths can never
// drift apart.
import {
rand, cheb, civUnits, civCities, cityById, canPropose, applyTreaty, declareWar,
cancelTreaty, giftGold, giftTech, exchangeTechs, payTribute,
bumpAttitude, bumpFrustration, frustrationOf, civPower,
} from './CivilizationLogic.js';
export const REQUEST_KINDS = [
'joinWar', 'breakTreaty', 'borderUltimatum', 'demandGold', 'demandTech',
'techTrade', 'ceasefire', 'peace', 'alliance',
];
// Kinds the leader proposes on its own initiative, in tiers of urgency (the
// treaty kinds are still decided by doDiplomacy's existing attitude/power
// logic and merely routed through this pipeline). A leader only gets one
// approach every perLeaderGap turns, so tiers matter: without them a filler
// tech trade regularly burns the slot a leader needed for a call to arms, and
// by the time they can speak again the warm feelings that qualified them have
// drifted back to baseline.
const INITIATIVE_TIERS = [
['joinWar', 'borderUltimatum'],
['breakTreaty'],
['techTrade'], // filler — dropped once there's an open grievance, see below
];
const DEFAULT_KIND = {
cooldown: 20, refuseFrustration: 10, refuseAttitude: -5, acceptAttitude: 10, acceptFrustration: -20,
};
const BORDER_RANGE = 3; // matches updateAttitudes' border-friction radius
const BORDER_MIN_UNITS = 2;
const PLEDGE_TURNS = 8;
function kindCfg(rules, kind) { return rules.requestKinds?.[kind] ?? DEFAULT_KIND; }
function tuning(rules) { return rules.diplomacy ?? {}; }
function pickOne(state, arr) { return arr[Math.floor(rand(state) * arr.length)]; }
// ---------------------------------------------------------------------------
// Cooldown bookkeeping (defensive everywhere — saves predate these fields)
export function cooldownReady(rules, state, fromIdx, toIdx, kind) {
const until = state.civs[fromIdx].requestCooldown?.[toIdx]?.[kind] ?? -Infinity;
return state.turn >= until;
}
export function setCooldown(rules, state, fromIdx, toIdx, kind) {
const civ = state.civs[fromIdx];
civ.requestCooldown ??= {};
(civ.requestCooldown[toIdx] ??= {})[kind] = state.turn + kindCfg(rules, kind).cooldown;
civ.lastRequestTurn ??= {};
civ.lastRequestTurn[toIdx] = state.turn;
}
function leaderReady(rules, state, fromIdx, toIdx) {
const last = state.civs[fromIdx].lastRequestTurn?.[toIdx];
if (last === undefined) return true;
return state.turn - last >= (tuning(rules).perLeaderGap ?? 10);
}
// ---------------------------------------------------------------------------
// Request generation
// Returns a request `fromIdx` wants to make of `toIdx` this turn, or null.
// Cheap early bails first: this runs for every rival on every AI turn.
export function considerRequest(rules, state, fromIdx, toIdx, strategy = {}) {
const civ = state.civs[fromIdx];
const other = state.civs[toIdx];
if (!civ.alive || !other.alive) return null;
const rel = civ.relations[toIdx];
if (rel === 'nocontact' || rel === 'war') return null;
if (!leaderReady(rules, state, fromIdx, toIdx)) return null;
const grudge = frustrationOf(state, fromIdx, toIdx);
const attitude = civ.attitude[toIdx] ?? 0;
// A significantly frustrated leader stops asking favours and starts asking
// for compensation.
if (grudge >= (tuning(rules).frustrationDemandThreshold ?? 45)) {
const demands = [makeDemandGold(rules, state, fromIdx, toIdx),
makeDemandTech(rules, state, fromIdx, toIdx)]
.filter((r) => r && cooldownReady(rules, state, fromIdx, toIdx, r.kind));
return demands.length ? pickOne(state, demands) : null;
}
// Ordinary initiative. aiAggression scales how pushy the world is, so
// Chieftain leaders pester noticeably less than Emperor ones.
if (rand(state) > 0.35 * (strategy.aggression ?? 0.5) + 0.15) return null;
const builders = {
joinWar: makeJoinWar,
breakTreaty: makeBreakTreaty,
borderUltimatum: makeBorderUltimatum,
techTrade: makeTechTrade,
};
// With a grievance already open, the leader presses the matter instead of
// making small talk. Without this a friendly tech-swap offer keeps winning
// the one slot they get every perLeaderGap turns, and the grudge decays away
// between asks — refusing everything would never escalate to anything.
const tiers = grudge > 0 ? INITIATIVE_TIERS.slice(0, -1) : INITIATIVE_TIERS;
for (const tier of tiers) {
const options = [];
for (const kind of tier) {
if (!cooldownReady(rules, state, fromIdx, toIdx, kind)) continue;
const req = builders[kind](rules, state, fromIdx, toIdx, attitude);
if (req) options.push(req);
}
if (options.length) return pickOne(state, options);
}
return null;
}
function baseRequest(kind, fromIdx, toIdx, state, extra = {}) {
return { kind, from: fromIdx, to: toIdx, turn: state.turn, ...extra };
}
// "You are my friend and I am at war — join me." Needs a real treaty and warm
// feelings; the target must know the third party and not already be fighting.
function makeJoinWar(rules, state, fromIdx, toIdx, attitude) {
const civ = state.civs[fromIdx];
const other = state.civs[toIdx];
if (civ.relations[toIdx] !== 'peace' && civ.relations[toIdx] !== 'alliance') return null;
if (attitude < (tuning(rules).goodStandingAttitude ?? 25)) return null;
const targets = state.civs.filter((c) => c.alive && c.id !== fromIdx && c.id !== toIdx
&& civ.relations[c.id] === 'war'
&& other.relations[c.id] !== 'nocontact' && other.relations[c.id] !== 'war');
if (!targets.length) return null;
return baseRequest('joinWar', fromIdx, toIdx, state, { target: pickOne(state, targets).id });
}
// The softer version: stop consorting with my enemy.
function makeBreakTreaty(rules, state, fromIdx, toIdx, attitude) {
const civ = state.civs[fromIdx];
const other = state.civs[toIdx];
if (attitude < Math.round((tuning(rules).goodStandingAttitude ?? 25) / 2)) return null;
const targets = state.civs.filter((c) => c.alive && c.id !== fromIdx && c.id !== toIdx
&& (civ.relations[c.id] === 'war' || (civ.attitude[c.id] ?? 0) <= -25)
&& (other.relations[c.id] === 'peace' || other.relations[c.id] === 'alliance'));
if (!targets.length) return null;
return baseRequest('breakTreaty', fromIdx, toIdx, state, { target: pickOne(state, targets).id });
}
// "Your soldiers are camped outside my walls." Same predicate updateAttitudes
// uses for border friction, so the ultimatum always matches what the leader is
// actually annoyed about.
function makeBorderUltimatum(rules, state, fromIdx, toIdx) {
const massed = massedNear(rules, state, fromIdx, toIdx);
if (!massed) return null;
return baseRequest('borderUltimatum', fromIdx, toIdx, state,
{ cityId: massed.id, cityName: massed.name });
}
// The city of `ownerIdx` with the most of `intruderIdx`'s combat units nearby,
// or null if nobody is massing.
function massedNear(rules, state, ownerIdx, intruderIdx) {
const intruders = civUnits(state, intruderIdx)
.filter((u) => !rules.units[u.type].flags.includes('noncombat'));
if (intruders.length < BORDER_MIN_UNITS) return null;
let best = null;
let bestCount = BORDER_MIN_UNITS - 1;
for (const city of civCities(state, ownerIdx)) {
const count = intruders.filter((u) => cheb(city.x, city.y, u.x, u.y) <= BORDER_RANGE).length;
if (count > bestCount) { best = city; bestCount = count; }
}
return best;
}
function makeTechTrade(rules, state, fromIdx, toIdx, attitude) {
if (attitude <= 0) return null;
const trade = pickFairTrade(rules, state, fromIdx, toIdx);
if (!trade) return null;
return baseRequest('techTrade', fromIdx, toIdx, state, trade);
}
// The cheapest tech each side can offer the other, when they're within one
// rank of each other. Shared with CivilizationAI's AI-to-AI trading.
export function pickFairTrade(rules, state, a, b) {
const civA = state.civs[a];
const civB = state.civs[b];
const aOffers = Object.keys(civA.known).filter((t) => !civB.known[t]);
const bOffers = Object.keys(civB.known).filter((t) => !civA.known[t]);
if (!aOffers.length || !bOffers.length) return null;
aOffers.sort((x, y) => (rules.techRank[x] ?? 0) - (rules.techRank[y] ?? 0));
bOffers.sort((x, y) => (rules.techRank[x] ?? 0) - (rules.techRank[y] ?? 0));
const giveId = aOffers[0];
const getId = bOffers[0];
if (Math.abs((rules.techRank[giveId] ?? 0) - (rules.techRank[getId] ?? 0)) > 1) return null;
return { giveId, getId };
}
// Compensation demands. Scaled to what the other side can actually pay, so a
// broke player isn't handed an impossible ultimatum.
function makeDemandGold(rules, state, fromIdx, toIdx) {
const purse = state.civs[toIdx].gold;
const gold = Math.min(400, Math.round(purse * 0.25));
if (gold < 25) return null;
return baseRequest('demandGold', fromIdx, toIdx, state, { gold });
}
function makeDemandTech(rules, state, fromIdx, toIdx) {
const civ = state.civs[fromIdx];
const wanted = Object.keys(state.civs[toIdx].known).filter((t) => !civ.known[t]);
if (!wanted.length) return null;
wanted.sort((x, y) => (rules.techRank[x] ?? 0) - (rules.techRank[y] ?? 0));
return baseRequest('demandTech', fromIdx, toIdx, state, { techId: wanted[0] });
}
// Treaty proposals reuse the request pipeline so they get the same audience UI
// and the same cooldowns. doDiplomacy still decides WHEN to propose.
export function makeTreatyRequest(rules, state, fromIdx, toIdx, kind) {
if (!canPropose(state, fromIdx, toIdx, kind)) return null;
if (!cooldownReady(rules, state, fromIdx, toIdx, kind)) return null;
return baseRequest(kind, fromIdx, toIdx, state);
}
// ---------------------------------------------------------------------------
// Validation & resolution
// Re-checked right before the request is shown: the world moved on while the
// rest of the AI round played out (the war ended, the civ died, the tech was
// researched anyway, the treasury emptied).
export function requestValid(rules, state, req) {
if (!req) return false;
const from = state.civs[req.from];
const to = state.civs[req.to];
if (!from?.alive || !to?.alive) return false;
const rel = from.relations[req.to];
if (rel === 'nocontact') return false;
const target = req.target !== undefined ? state.civs[req.target] : null;
switch (req.kind) {
case 'joinWar':
return !!target?.alive && from.relations[req.target] === 'war'
&& to.relations[req.target] !== 'war' && to.relations[req.target] !== 'nocontact';
case 'breakTreaty':
return !!target?.alive
&& (to.relations[req.target] === 'peace' || to.relations[req.target] === 'alliance');
case 'borderUltimatum':
return !!cityById(state, req.cityId) && rel !== 'war';
case 'demandGold':
return to.gold >= req.gold;
case 'demandTech':
return !!to.known[req.techId] && !from.known[req.techId];
case 'techTrade':
return !!from.known[req.giveId] && !to.known[req.giveId]
&& !!to.known[req.getId] && !from.known[req.getId];
default:
return canPropose(state, req.from, req.to, req.kind);
}
}
// Applies a request's outcome. `weight` scales the emotional consequences —
// 0.5 for a request the player let lapse without answering, since ignoring is
// milder than refusing to their face. Returns true if the mechanical effect
// actually landed.
export function resolveRequest(rules, state, req, accepted, weight = 1) {
const cfg = kindCfg(rules, req.kind);
const { from, to } = req;
setCooldown(rules, state, from, to, req.kind);
let applied = true;
if (accepted) {
switch (req.kind) {
case 'joinWar': {
// Deliberately the ordinary declareWar: answering a call to arms
// against someone you had a treaty with still scars your reputation
// in front of the whole world. The one exception is the leader who
// asked for it — declareWar sours every onlooker on a sneak attack,
// and it would be perverse for the requester to be appalled by the
// favour they just demanded, so undo their share of that penalty.
applied = declareWar(rules, state, to, req.target);
const decl = state.events[state.events.length - 1];
if (applied && decl?.type === 'war' && decl.sneak) bumpAttitude(state, from, to, 20);
break;
}
case 'breakTreaty':
applied = cancelTreaty(state, to, req.target);
break;
case 'borderUltimatum': {
// Nothing can force the units to move, so accepting records a promise
// that is checked when it expires (see checkPledges).
const civ = state.civs[from];
civ.pledges ??= {};
civ.pledges[to] = { kind: 'withdraw', cityId: req.cityId, untilTurn: state.turn + PLEDGE_TURNS };
break;
}
case 'demandGold':
applied = payTribute(state, to, from, req.gold) > 0;
break;
case 'demandTech':
applied = giftTech(rules, state, to, from, req.techId);
break;
case 'techTrade':
applied = exchangeTechs(rules, state, from, to, req.giveId, req.getId);
break;
default:
applied = applyTreaty(state, from, to, req.kind);
break;
}
if (applied) {
bumpAttitude(state, from, to, cfg.acceptAttitude);
bumpFrustration(state, from, to, cfg.acceptFrustration);
}
} else {
bumpAttitude(state, from, to, Math.round(cfg.refuseAttitude * weight));
bumpFrustration(state, from, to, Math.round(cfg.refuseFrustration * weight));
}
state.events.push({
type: 'requestResolved', from, to, kind: req.kind, accepted: accepted && applied,
target: req.target, gold: req.gold, techId: req.techId, keep: true,
});
return applied;
}
// How an AI-controlled recipient answers. Mirrors respondToProposal's shape so
// AI civs can drag each other into wars.
export function aiWouldAccept(rules, state, req) {
const civ = state.civs[req.to];
const attitude = civ.attitude[req.from] ?? 0;
const powerRatio = civPower(rules, state, req.to) / Math.max(1, civPower(rules, state, req.from));
switch (req.kind) {
case 'joinWar': {
// Only for a friend, and only against someone you can afford to fight.
const targetPower = civPower(rules, state, req.target);
const canAfford = civPower(rules, state, req.to) > targetPower * 0.8;
return attitude > 45 && canAfford && (civ.attitude[req.target] ?? 0) < 20;
}
case 'breakTreaty':
return attitude > 35 && attitude > (civ.attitude[req.target] ?? 0) + 20;
case 'borderUltimatum':
return powerRatio < 1.2 || attitude > 20;
case 'demandGold':
case 'demandTech':
// Give in to someone stronger, or to a friend worth keeping.
return powerRatio < 0.8 || attitude > 40;
case 'techTrade':
return attitude > -10;
default:
return false; // treaty kinds go through respondToProposal
}
}
// ---------------------------------------------------------------------------
// Frustration escalation & follow-through
// Called once per rival per AI turn, before the war checks in doDiplomacy. A
// leader past breaking point tears up whatever treaty it has; if the grudge
// climbs back with no treaty left to break, ordinary hostility takes it to war.
export function escalateFrustration(rules, state, fromIdx, towardIdx) {
const civ = state.civs[fromIdx];
if (frustrationOf(state, fromIdx, towardIdx) < (tuning(rules).frustrationBreakThreshold ?? 70)) return false;
const rel = civ.relations[towardIdx];
if (rel !== 'peace' && rel !== 'alliance' && rel !== 'ceasefire') return false;
if (!cancelTreaty(state, fromIdx, towardIdx)) return false;
// Tearing up the treaty is itself satisfaction: vent back to half the demand
// threshold. That drops the attitude drag sharply and puts the leader below
// the compensation-demand line again, so a player who immediately makes
// amends (a gift, a tech) has a real chance to pull back from war.
civ.frustration ??= {};
civ.frustration[towardIdx] = (tuning(rules).frustrationDemandThreshold ?? 45) / 2;
state.events.push({ type: 'treatyRenounced', from: fromIdx, to: towardIdx, was: rel, keep: true });
return true;
}
// Border-ultimatum promises come due. Units still parked outside the city when
// the pledge expires costs double what refusing outright would have.
export function checkPledges(rules, state, civIdx) {
const civ = state.civs[civIdx];
const pledges = civ.pledges;
if (!pledges) return;
for (const key of Object.keys(pledges)) {
const pledge = pledges[key];
if (!pledge || state.turn < pledge.untilTurn) continue;
const otherIdx = Number(key);
delete pledges[key];
const city = cityById(state, pledge.cityId);
if (!city || city.civ !== civIdx) continue;
const cfg = kindCfg(rules, 'borderUltimatum');
const stillThere = civUnits(state, otherIdx).some((u) => !rules.units[u.type].flags.includes('noncombat')
&& cheb(city.x, city.y, u.x, u.y) <= BORDER_RANGE);
if (stillThere) {
bumpAttitude(state, civIdx, otherIdx, cfg.refuseAttitude * 2);
bumpFrustration(state, civIdx, otherIdx, cfg.refuseFrustration * 2);
state.events.push({ type: 'pledgeBroken', from: civIdx, to: otherIdx, cityName: city.name, keep: true });
} else {
bumpAttitude(state, civIdx, otherIdx, 10);
state.events.push({ type: 'pledgeKept', from: civIdx, to: otherIdx, cityName: city.name, keep: true });
}
}
}
// ---------------------------------------------------------------------------
// Unprompted gifts — no accept/refuse, just a leader who genuinely likes you.
export function considerGift(rules, state, fromIdx, toIdx) {
const civ = state.civs[fromIdx];
const attitude = civ.attitude[toIdx] ?? 0;
const t = tuning(rules);
if (attitude < (t.giftAttitude ?? 70)) return false;
const rel = civ.relations[toIdx];
if (rel !== 'peace' && rel !== 'alliance') return false;
const until = civ.requestCooldown?.[toIdx]?.gift ?? -Infinity;
if (state.turn < until) return false;
// Techs first — a gift the recipient can't buy is worth more than coin.
const spare = Object.keys(civ.known).filter((tech) => !state.civs[toIdx].known[tech]);
let gift = null;
if (spare.length && rand(state) < 0.5) {
const techId = pickOne(state, spare);
if (giftTech(rules, state, fromIdx, toIdx, techId)) gift = { techId };
}
if (!gift && civ.gold >= 150) {
const gold = 50 + Math.floor(rand(state) * 3) * 25;
if (giftGold(state, fromIdx, toIdx, gold)) gift = { gold };
}
if (!gift) return false;
civ.requestCooldown ??= {};
(civ.requestCooldown[toIdx] ??= {}).gift = state.turn + (t.giftCooldown ?? 30);
state.events.push({ type: 'aiGift', from: fromIdx, to: toIdx, ...gift, keep: true });
return true;
}
// ---------------------------------------------------------------------------
// UI support
// One-line warning about what accepting actually costs, shown under the
// ACCEPT/REFUSE buttons. Empty string when there's no hidden sting.
export function requestConsequence(rules, state, req) {
const to = state.civs[req.to];
switch (req.kind) {
case 'joinWar': {
const rel = to.relations[req.target];
const name = state.civs[req.target].name;
if (rel === 'peace' || rel === 'alliance') {
return `Accepting breaks your ${rel} with ${name} — a sneak attack that will scar your reputation.`;
}
return `Accepting declares war on ${name}.`;
}
case 'breakTreaty':
return `Accepting renounces your treaty with ${state.civs[req.target].name}, who will not forget it.`;
case 'borderUltimatum':
return `Accepting promises to pull your units back from ${req.cityName} within ${PLEDGE_TURNS} turns.`;
case 'demandGold':
return `Accepting pays ${req.gold} gold from your treasury of ${to.gold}.`;
case 'demandTech':
return `Accepting hands over ${rules.techs[req.techId].name}.`;
case 'techTrade':
return `You give ${rules.techs[req.giveId].name} and receive ${rules.techs[req.getId].name}.`;
default:
return '';
}
}
// Template variables for the chat pools in CivilizationChat.js.
export function requestVars(rules, state, req) {
return {
target: req.target !== undefined ? state.civs[req.target].name : '',
city: req.cityName ?? '',
gold: req.gold ?? 0,
tech: req.techId ? rules.techs[req.techId].name : '',
give: req.giveId ? rules.techs[req.giveId].name : '',
get: req.getId ? rules.techs[req.getId].name : '',
};
}

View File

@ -15,6 +15,8 @@ import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
import { compileRules, turnToYear, formatYear } from './CivilizationRules.js'; import { compileRules, turnToYear, formatYear } from './CivilizationRules.js';
import * as Logic from './CivilizationLogic.js'; import * as Logic from './CivilizationLogic.js';
import { runAITurn, respondToProposal } from './CivilizationAI.js'; import { runAITurn, respondToProposal } from './CivilizationAI.js';
import { requestValid, resolveRequest } from './CivilizationDiplomacy.js';
import { RENOUNCE, GIFT_GOLD, GIFT_TECH, pickLine } from './CivilizationChat.js';
import { CivilizationMapView } from './CivilizationMapView.js'; import { CivilizationMapView } from './CivilizationMapView.js';
import { openCityScreen } from './CivilizationCityScreen.js'; import { openCityScreen } from './CivilizationCityScreen.js';
import { import {
@ -1260,7 +1262,7 @@ export default class CivilizationGame extends Phaser.Scene {
} else if (needsPick) { } else if (needsPick) {
this.openTech(); this.openTech();
} }
this.presentAIProposals(); this.presentAIRequests();
this.announceCityAttacks(); this.announceCityAttacks();
this.announceEvents(); this.announceEvents();
this.view.refresh(); this.view.refresh();
@ -1441,6 +1443,21 @@ export default class CivilizationGame extends Phaser.Scene {
this.announceStatus(`${this.state.civs[e.civ].name} has been destroyed`); this.announceStatus(`${this.state.civs[e.civ].name} has been destroyed`);
} else if (e.type === 'spaceshipLaunched') { } else if (e.type === 'spaceshipLaunched') {
this.announceStatus(`${this.state.civs[e.civ].name} launched a spaceship!`); this.announceStatus(`${this.state.civs[e.civ].name} launched a spaceship!`);
} else if (e.type === 'treatyRenounced' && e.to === human) {
// A grudge that finally boiled over (CivilizationDiplomacy.js).
this.leaderSays(e.from, pickLine(RENOUNCE, this.chatVars(e.from)));
this.announceStatus(`${this.state.civs[e.from].name} has renounced your ${e.was === 'ceasefire' ? 'cease-fire' : e.was}!`,
() => this.openDiplomacy({ focusCivId: e.from }));
} else if (e.type === 'pledgeBroken' && e.to === human) {
this.announceStatus(`${this.state.civs[e.from].name} accuses you of breaking your word over ${e.cityName}.`);
} else if (e.type === 'aiGift' && e.to === human) {
const giver = this.state.civs[e.from].name;
const vars = { ...this.chatVars(e.from), gold: e.gold, tech: e.techId ? this.rules.techs[e.techId].name : '' };
this.leaderSays(e.from, pickLine(e.techId ? GIFT_TECH : GIFT_GOLD, vars));
this.announceStatus(e.techId
? `${giver} sends you the secrets of ${this.rules.techs[e.techId].name} as a gift!`
: `${giver} sends you a gift of ${e.gold} gold!`,
() => this.openDiplomacy({ focusCivId: e.from }));
} else if (e.type === 'contact' && (e.a === human || e.b === human)) { } else if (e.type === 'contact' && (e.a === human || e.b === human)) {
const other = e.a === human ? e.b : e.a; const other = e.a === human ? e.b : e.a;
this.announceStatus(`You have made contact with ${this.state.civs[other].name}`, this.announceStatus(`You have made contact with ${this.state.civs[other].name}`,
@ -1488,22 +1505,55 @@ export default class CivilizationGame extends Phaser.Scene {
} }
} }
presentAIProposals() { // Appends a line to a leader's conversation log (state.chatLog, the same
// history the diplomacy screen types out) for things they "say" outside that
// screen — renounced treaties, unsolicited gifts. Plain data, so it rides
// along in the save like the rest of the log.
leaderSays(civId, text) {
this.state.chatLog ??= {};
const hist = (this.state.chatLog[civId] ??= []);
hist.push({ who: 'o', text });
if (hist.length > 50) hist.splice(0, hist.length - 50);
}
chatVars(civId) {
return { you: this.state.civs[this.state.humanIndex].name, me: this.state.civs[civId].name };
}
// One AI-initiated audience per turn (rules.diplomacy.maxPerTurn): a popup
// announcing who wants to see you, then the diplomacy screen focused on them
// with ACCEPT/REFUSE in place of the usual actions. Requests the player
// closes without answering stay queued and come back next turn; after
// lapseTurns of that they resolve as a half-weight refusal, so ignoring a
// leader is milder than refusing to their face but not free.
presentAIRequests() {
const human = this.state.humanIndex; const human = this.state.humanIndex;
const proposals = this.state.events.filter((e) => e.type === 'aiProposal' && e.to === human); const tuning = this.rules.diplomacy ?? {};
this.state.events = this.state.events.filter((e) => !(e.type === 'aiProposal' && e.to === human)); const queue = this.state.pendingRequests ?? [];
const next = () => { // Drop requests the world has invalidated, and lapse the stale ones.
const p = proposals.shift(); this.state.pendingRequests = queue.filter((req) => {
if (!p) { this.showNextStatus(); return; } if (!requestValid(this.rules, this.state, req)) return false;
if (!Logic.canPropose(this.state, p.from, human, p.kind)) { next(); return; } if (this.state.turn - req.turn >= (tuning.lapseTurns ?? 3)) {
const from = this.state.civs[p.from]; resolveRequest(this.rules, this.state, req, false, 0.5);
this.confirmDialog( return false;
`${from.name} proposes a ${p.kind === 'ceasefire' ? 'cease-fire' : p.kind}. Accept?`, }
() => { Logic.applyTreaty(this.state, p.from, human, p.kind); this.refreshHud(); next(); }, return true;
() => next(), });
);
}; // announceStatus owns the queue (and kicks it when nothing else is open),
next(); // so there is nothing to resume here when no request is due.
const max = tuning.maxPerTurn ?? 1;
for (const req of this.state.pendingRequests.slice(0, max)) {
const from = this.state.civs[req.from];
this.announceStatus(`${from.name} requests an audience`, () => this.openDiplomacy({
focusCivId: req.from,
request: req,
onResolved: (resolved) => {
this.state.pendingRequests = this.state.pendingRequests.filter((p) => p !== resolved);
this.refreshHud();
},
}));
}
} }
toastHut(hut) { toastHut(hut) {
@ -1584,7 +1634,7 @@ export default class CivilizationGame extends Phaser.Scene {
}); });
} }
openDiplomacy({ focusCivId = null, playIntro = false } = {}) { openDiplomacy({ focusCivId = null, playIntro = false, request = null, onResolved = null } = {}) {
if (this.modalOpen) return; if (this.modalOpen) return;
this.modalOpen = true; this.modalOpen = true;
openDiplomacyScreen(this, this.rules, this.state, this.opponentsData, respondToProposal, () => { openDiplomacyScreen(this, this.rules, this.state, this.opponentsData, respondToProposal, () => {
@ -1592,7 +1642,7 @@ export default class CivilizationGame extends Phaser.Scene {
this.view.refresh(); this.view.refresh();
this.refreshHud(); this.refreshHud();
this.showNextStatus(); this.showNextStatus();
}, focusCivId, playIntro); }, { focusCivId, playIntro, request, onResolved });
} }
openSpaceship() { openSpaceship() {

View File

@ -79,6 +79,10 @@ export function createGame(rules, opts) {
explored: [], explored: [],
over: null, over: null,
events: [], events: [],
// AI-initiated diplomacy aimed at the human, kept out of `events` so it
// survives the event-log trim in beginCivTurn and can stay pending across
// turns until answered (see CivilizationDiplomacy.js).
pendingRequests: [],
}; };
const nameOrder = shuffledIndexes(state, rules.cityNames.length); const nameOrder = shuffledIndexes(state, rules.cityNames.length);
@ -111,6 +115,17 @@ export function createGame(rules, opts) {
futureCount: 0, futureCount: 0,
relations, relations,
attitude, attitude,
// AI-initiated diplomacy bookkeeping, all keyed by the other civ's id.
// frustration (0-100) is grudge built up by refused requests; it decays
// each turn and drags `attitude` down while it lasts. requestCooldown is
// {otherId: {kind: turnAvailableAgain}}, lastRequestTurn throttles a
// single leader, pledges holds promises the other civ made (e.g. to
// withdraw units). Consumers must tolerate these being absent — old
// saves predate them.
frustration: {},
requestCooldown: {},
lastRequestTurn: {},
pledges: {},
reputation: 0, reputation: 0,
spaceship: { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 }, spaceship: { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 },
nameCursor: i, // stride through the shared shuffled name pool nameCursor: i, // stride through the shared shuffled name pool
@ -1530,6 +1545,21 @@ export function bumpAttitude(state, ofCiv, towardCiv, delta) {
} }
function clampAttitude(v) { return Math.max(-100, Math.min(100, v)); } function clampAttitude(v) { return Math.max(-100, Math.min(100, v)); }
// Frustration: a grudge counter (0-100) separate from attitude, raised when
// `ofCiv`'s requests are refused. It decays every turn (updateAttitudes) and
// pulls attitude down while it lasts, so a leader you keep brushing off slides
// toward war through the ordinary hostility path rather than a special case.
export function frustrationOf(state, ofCiv, towardCiv) {
return state.civs[ofCiv].frustration?.[towardCiv] ?? 0;
}
export function bumpFrustration(state, ofCiv, towardCiv, delta) {
const civ = state.civs[ofCiv];
civ.frustration ??= {};
civ.frustration[towardCiv] = Math.max(0, Math.min(100, (civ.frustration[towardCiv] ?? 0) + delta));
return civ.frustration[towardCiv];
}
export function civPower(rules, state, civIdx) { export function civPower(rules, state, civIdx) {
let power = 0; let power = 0;
for (const u of civUnits(state, civIdx)) { for (const u of civUnits(state, civIdx)) {
@ -1566,6 +1596,10 @@ export function updateAttitudes(rules, state, civIdx) {
if (civ.relations[other.id] === 'war') baseline -= 40; if (civ.relations[other.id] === 'war') baseline -= 40;
if (civ.relations[other.id] === 'alliance') baseline += 30; if (civ.relations[other.id] === 'alliance') baseline += 30;
baseline += Math.max(-50, other.reputation / 2); baseline += Math.max(-50, other.reputation / 2);
// Refused requests sour the baseline for as long as the grudge lasts, and
// the grudge itself fades a little every turn.
const grudge = bumpFrustration(state, civIdx, other.id, -(rules.diplomacy?.frustrationDecay ?? 2));
baseline -= Math.round(grudge * (rules.diplomacy?.frustrationAttitudeWeight ?? 0.5));
baseline = clampAttitude(baseline); baseline = clampAttitude(baseline);
const cur = civ.attitude[other.id] ?? 0; const cur = civ.attitude[other.id] ?? 0;
civ.attitude[other.id] = clampAttitude(cur + Math.sign(baseline - cur) * Math.min(3, Math.abs(baseline - cur))); civ.attitude[other.id] = clampAttitude(cur + Math.sign(baseline - cur) * Math.min(3, Math.abs(baseline - cur)));

View File

@ -102,6 +102,38 @@ export function compileRules(json) {
} }
if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`); if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`);
// --- AI-initiated diplomacy tuning. Every scalar has a default so a rules
// file written before this block still compiles; when the block IS present
// its numbers are validated, since a typo here silently disables requests.
const diploJson = json.diplomacyRequests ?? {};
const diplomacy = {
maxPerTurn: diploJson.maxPerTurn ?? 1,
perLeaderGap: diploJson.perLeaderGap ?? 10,
goodStandingAttitude: diploJson.goodStandingAttitude ?? 25,
giftAttitude: diploJson.giftAttitude ?? 70,
giftCooldown: diploJson.giftCooldown ?? 30,
lapseTurns: diploJson.lapseTurns ?? 3,
frustrationDecay: diploJson.frustrationDecay ?? 2,
frustrationDemandThreshold: diploJson.frustrationDemandThreshold ?? 45,
frustrationBreakThreshold: diploJson.frustrationBreakThreshold ?? 70,
frustrationAttitudeWeight: diploJson.frustrationAttitudeWeight ?? 0.5,
};
for (const [k, v] of Object.entries(diplomacy)) {
need(typeof v === 'number' && v >= 0, `diplomacyRequests ${k} must be a non-negative number`);
}
need(diplomacy.frustrationBreakThreshold > diplomacy.frustrationDemandThreshold,
'diplomacyRequests frustrationBreakThreshold must exceed frustrationDemandThreshold');
const requestKinds = byId(diploJson.kinds ?? [], 'diplomacyRequests kind');
for (const k of diploJson.kinds ?? []) {
for (const f of ['cooldown', 'refuseFrustration', 'refuseAttitude', 'acceptAttitude', 'acceptFrustration']) {
need(typeof k[f] === 'number', `diplomacyRequests kind ${k.id} bad ${f}`);
}
need(k.cooldown > 0, `diplomacyRequests kind ${k.id} needs a positive cooldown`);
need(k.refuseFrustration >= 0, `diplomacyRequests kind ${k.id} refuseFrustration must be >= 0`);
need(k.acceptFrustration <= 0, `diplomacyRequests kind ${k.id} acceptFrustration must be <= 0`);
}
if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`);
// --- derived: what each tech unlocks (for UI hovers and the "every tech // --- derived: what each tech unlocks (for UI hovers and the "every tech
// matters" verify check) // matters" verify check)
const gates = {}; const gates = {};
@ -133,6 +165,8 @@ export function compileRules(json) {
civTraitList: json.civTraits, civTraitList: json.civTraits,
improvementList: json.improvements ?? [], improvementList: json.improvements ?? [],
worldSizeList: json.worldSizes ?? [], worldSizeList: json.worldSizes ?? [],
diplomacy,
requestKinds,
techRank: rank, techRank: rank,
techGates: gates, techGates: gates,
specialsByTerrain, specialsByTerrain,

View File

@ -8,7 +8,10 @@ import { createOpponentPortrait } from '../../ui/Portrait.js';
import { Tooltip } from '../../ui/Tooltip.js'; import { Tooltip } from '../../ui/Tooltip.js';
import * as Logic from './CivilizationLogic.js'; import * as Logic from './CivilizationLogic.js';
import { describeTechTooltip, GOVERNMENT_TEXT } from './CivilizationTooltips.js'; import { describeTechTooltip, GOVERNMENT_TEXT } from './CivilizationTooltips.js';
import { OPENERS, PLAYER_LINES, REPLIES, pickLine } from './CivilizationChat.js'; import {
OPENERS, PLAYER_LINES, REPLIES, REQUESTS, REQUEST_REPLIES, REQUEST_ANSWERS, pickLine,
} from './CivilizationChat.js';
import { requestConsequence, requestVars, resolveRequest } from './CivilizationDiplomacy.js';
const FONT = '"Julius Sans One"'; const FONT = '"Julius Sans One"';
const ERAS = ['ancient', 'medieval', 'industrial', 'modern']; const ERAS = ['ancient', 'medieval', 'industrial', 'modern'];
@ -116,9 +119,15 @@ export function openTechScreen(scene, rules, state, onClose) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Diplomacy // Diplomacy
// opts: { focusCivId, playIntro, request, onResolved }. `request` is an
// AI-initiated request (CivilizationDiplomacy.js) the player is being asked to
// answer — it replaces the action buttons with ACCEPT/REFUSE for that leader
// until answered, and onResolved(request, accepted) fires once they do.
export function openDiplomacyScreen(scene, rules, state, opponentsData, respondToProposal, onClose, export function openDiplomacyScreen(scene, rules, state, opponentsData, respondToProposal, onClose,
focusCivId = null, playIntroOnFocus = false) { { focusCivId = null, playIntro: playIntroOnFocus = false, request = null, onResolved = null } = {}) {
const human = state.humanIndex; const human = state.humanIndex;
let pending = request; // cleared once answered, so the panel returns to normal
let requestSpoken = false; // the demand is typed into the log exactly once
const civ = state.civs[human]; const civ = state.civs[human];
let portrait = null; let portrait = null;
let portraitCivId = null; let portraitCivId = null;
@ -271,6 +280,7 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
// addChatText measures the full string first, so the message's slot height // addChatText measures the full string first, so the message's slot height
// is reserved before the reveal starts — no reflow while typing. // is reserved before the reveal starts — no reflow while typing.
function pushChat(who, text) { function pushChat(who, text) {
if (!text) return; // pickLine returns '' for a missing pool — no empty bubbles
const hist = historyFor(selected.id); const hist = historyFor(selected.id);
hist.push({ who, text }); hist.push({ who, text });
if (hist.length > 50) hist.splice(0, hist.length - 50); if (hist.length > 50) hist.splice(0, hist.length - 50);
@ -339,6 +349,16 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
} }
const vars = { you: civ.name, me: other.name }; const vars = { you: civ.name, me: other.name };
// The leader states their business once, the first time this screen draws
// them — drawDetail re-runs on every button press, so guard with a flag.
const asking = pending && pending.from === other.id;
if (asking && !requestSpoken) {
requestSpoken = true;
pushChat('o', pickLine(REQUESTS[pending.kind] ?? [], { ...vars, ...requestVars(rules, state, pending) }));
// Demands come from a leader who has already run out of patience.
if (pending.kind === 'demandGold' || pending.kind === 'demandTech') portrait?.playEmotion('upset');
}
const moodWord = { upset: 'is furious with you', idle: 'is indifferent', happy: 'is friendly' }[mood]; const moodWord = { upset: 'is furious with you', idle: 'is indifferent', happy: 'is friendly' }[mood];
detail.add(scene.add.text(cx, top + 330, detail.add(scene.add.text(cx, top + 330,
`${other.name} of the ${other.name}ites ${moodWord}.\nStatus: ${relLabel(rel)}`, { `${other.name} of the ${other.name}ites ${moodWord}.\nStatus: ${relLabel(rel)}`, {
@ -351,6 +371,35 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
fontFamily: FONT, fontSize: '18px', color: COLORS.mutedHex, fontFamily: FONT, fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5, 0)); }).setOrigin(0.5, 0));
// An outstanding request takes over the action area until answered: the
// player owes this leader a yes or a no before ordinary business resumes.
if (asking) {
const hint = requestConsequence(rules, state, pending);
if (hint) {
detail.add(scene.add.text(cx, top + 452, hint, {
fontFamily: FONT, fontSize: '17px', color: COLORS.goldHex, align: 'center',
wordWrap: { width: 700 }, lineSpacing: 4,
}).setOrigin(0.5, 0));
}
const answer = (accepted) => {
const req = pending;
pending = null;
pushChat('p', pickLine(REQUEST_ANSWERS[accepted ? 'accept' : 'refuse'], vars));
resolveRequest(rules, state, req, accepted);
pushChat('o', pickLine(REQUEST_REPLIES[req.kind]?.[accepted ? 'accept' : 'refuse'] ?? [],
{ ...vars, ...requestVars(rules, state, req) }));
portrait?.playEmotion(accepted ? 'happy' : 'upset');
onResolved?.(req, accepted);
drawDetail();
};
// Below the hint, which wraps to up to three lines.
detail.add(new Button(scene, cx - 170, top + 560, 'ACCEPT', () => answer(true),
{ width: 300, height: 56, fontSize: 20 }));
detail.add(new Button(scene, cx + 170, top + 560, 'REFUSE', () => answer(false),
{ width: 300, height: 56, fontSize: 20, variant: 'ghost' }));
return;
}
// Action buttons. // Action buttons.
const actions = []; const actions = [];
if (rel === 'war') actions.push(['PROPOSE CEASE-FIRE', () => propose('ceasefire')]); if (rel === 'war') actions.push(['PROPOSE CEASE-FIRE', () => propose('ceasefire')]);

View File

@ -26,6 +26,17 @@ function dim(scene, alpha = 0.72) {
return r; return r;
} }
// Title art behind the front-end screens (assets/images/tetrisattack/main-menu.png,
// declared in data/assetManifest.js). Falls back to the plain dim if it's absent.
// The veil on top is light — just enough for the menu text to stay readable.
function menuBackdrop(scene, veilAlpha = 0.35) {
if (!scene.textures.exists('tetrisattack-menu-bg')) return dim(scene, 0.9);
const img = scene.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'tetrisattack-menu-bg')
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(OVL - 1);
scene.overlayObjs.push(img);
return dim(scene, veilAlpha);
}
function textButton(scene, x, y, label, action, opts = {}) { function textButton(scene, x, y, label, action, opts = {}) {
const w = opts.width ?? 300, h = opts.height ?? 66; const w = opts.width ?? 300, h = opts.height ?? 66;
const bg = scene.add.rectangle(x, y, w, h, 0x243458, 1).setStrokeStyle(3, 0x5a7cbf).setDepth(OVL_UI); const bg = scene.add.rectangle(x, y, w, h, 0x243458, 1).setStrokeStyle(3, 0x5a7cbf).setDepth(OVL_UI);
@ -41,16 +52,10 @@ function textButton(scene, x, y, label, action, opts = {}) {
// ── Mode menu ──────────────────────────────────────────────────────────────── // ── Mode menu ────────────────────────────────────────────────────────────────
export function showMenu(scene) { export function showMenu(scene) {
scene.clearOverlay(); scene.clearOverlay();
dim(scene, 0.9); // The title art carries the name — no lettering drawn over it.
menuBackdrop(scene, 0.28);
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
scene.overlayObjs.push(scene.add.text(cx, 150, 'TETRIS ATTACK', {
fontFamily: FONT, fontSize: '96px', color: '#ffe66e', stroke: '#3a2a00', strokeThickness: 8,
}).setOrigin(0.5).setDepth(OVL_UI));
scene.overlayObjs.push(scene.add.text(cx, 240, 'Panel de Pon — 1 Player', {
fontFamily: FONT, fontSize: '32px', color: '#7de6ff',
}).setOrigin(0.5).setDepth(OVL_UI));
const modes = [ const modes = [
{ key: 'endless', name: scene.config?.endless?.name ?? 'Endless', desc: scene.config?.endless?.description ?? '', action: () => scene.startEndless() }, { key: 'endless', name: scene.config?.endless?.name ?? 'Endless', desc: scene.config?.endless?.description ?? '', action: () => scene.startEndless() },
{ key: 'stageclear', name: scene.config?.stageClear?.name ?? 'Stage Clear', desc: scene.config?.stageClear?.description ?? '', action: () => scene.startStageClear() }, { key: 'stageclear', name: scene.config?.stageClear?.name ?? 'Stage Clear', desc: scene.config?.stageClear?.description ?? '', action: () => scene.startStageClear() },
@ -124,7 +129,7 @@ export function showPuzzleSelect(scene) {
export function showStageSelect(scene) { export function showStageSelect(scene) {
scene.clearOverlay(); scene.clearOverlay();
scene.teardownGameplay(); scene.teardownGameplay();
dim(scene, 0.92); menuBackdrop(scene, 0.62);
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const rounds = scene.rounds; const rounds = scene.rounds;
const perRound = scene.stagesPerRound; const perRound = scene.stagesPerRound;

View File

@ -21,6 +21,8 @@ import { compileRules, techCost, turnToYear } from '../src/games/civilization/Ci
import { generateWorld, siteQuality, shieldGrassAt } from '../src/games/civilization/CivilizationWorldGen.js'; import { generateWorld, siteQuality, shieldGrassAt } from '../src/games/civilization/CivilizationWorldGen.js';
import * as Logic from '../src/games/civilization/CivilizationLogic.js'; import * as Logic from '../src/games/civilization/CivilizationLogic.js';
import * as AI from '../src/games/civilization/CivilizationAI.js'; import * as AI from '../src/games/civilization/CivilizationAI.js';
import * as Diplo from '../src/games/civilization/CivilizationDiplomacy.js';
import * as Chat from '../src/games/civilization/CivilizationChat.js';
const QUICK = process.argv.includes('--quick'); const QUICK = process.argv.includes('--quick');
const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const root = join(dirname(fileURLToPath(import.meta.url)), '..');
@ -1263,6 +1265,456 @@ if (RULES) {
&& Logic.attitudeMood(60) === 'happy'); && Logic.attitudeMood(60) === 'happy');
} }
// ---------------------------------------------------------------------------
section('6b. AI-initiated diplomacy');
// Note: makeFlatState/mkCiv deliberately DON'T carry frustration,
// requestCooldown, lastRequestTurn, pledges or pendingRequests — they're
// shaped like a save written before this feature existed, so every check below
// doubles as an old-save compatibility test.
if (RULES) {
// considerRequest rolls a random gate and picks among eligible kinds, so
// sample it repeatedly with the throttles cleared to see what a setup can
// actually produce.
const collect = (st, from, to, attempts = 200) => {
const kinds = new Set();
for (let i = 0; i < attempts; i += 1) {
st.civs[from].lastRequestTurn = {};
st.civs[from].requestCooldown = {};
const req = Diplo.considerRequest(RULES, st, from, to);
if (req) kinds.add(req.kind);
}
return kinds;
};
// --- join-my-war eligibility
{
const st = makeFlatState({ civs: 3 });
setWar(st, 1, 2);
st.civs[1].relations[0] = 'peace';
st.civs[0].relations[1] = 'peace';
st.civs[1].attitude[0] = 40;
check('joinWar offered to a friendly treaty partner', collect(st, 1, 0).has('joinWar'));
st.civs[1].attitude[0] = 10; // below goodStandingAttitude
check('joinWar withheld from a lukewarm partner', !collect(st, 1, 0).has('joinWar'));
st.civs[1].attitude[0] = 40;
st.civs[1].relations[0] = 'contact';
st.civs[0].relations[1] = 'contact';
check('joinWar needs a treaty, not mere contact', !collect(st, 1, 0).has('joinWar'));
st.civs[1].relations[0] = 'peace';
st.civs[0].relations[1] = 'peace';
setWar(st, 0, 2); // already fighting the proposed target
check('joinWar withheld when you already fight the target', !collect(st, 1, 0).has('joinWar'));
st.civs[0].relations[2] = 'nocontact';
st.civs[2].relations[0] = 'nocontact';
check('joinWar withheld against a civ you have never met', !collect(st, 1, 0).has('joinWar'));
}
// --- accepting a call to arms
{
const st = makeFlatState({ civs: 3 });
setWar(st, 1, 2);
Logic.applyTreaty(st, 0, 1, 'peace');
Logic.applyTreaty(st, 0, 2, 'peace');
const rep = st.civs[0].reputation;
const req = { kind: 'joinWar', from: 1, to: 0, target: 2, turn: st.turn };
check('joinWar accepted declares war', Diplo.resolveRequest(RULES, st, req, true)
&& st.civs[0].relations[2] === 'war');
check('breaking a treaty to answer a call to arms still scars reputation',
st.civs[0].reputation < rep);
check('answering the call earns real goodwill', st.civs[1].attitude[0] >= 30);
}
// --- refusal builds frustration, which decays and drags attitude down
{
const st = makeFlatState({ civs: 3 });
setWar(st, 1, 2);
Logic.applyTreaty(st, 0, 1, 'peace');
const cfg = RULES.requestKinds.joinWar;
const attBefore = st.civs[1].attitude[0];
const req = { kind: 'joinWar', from: 1, to: 0, target: 2, turn: st.turn };
Diplo.resolveRequest(RULES, st, req, false);
check('refusal builds frustration', Logic.frustrationOf(st, 1, 0) === cfg.refuseFrustration);
check('refusal costs attitude', st.civs[1].attitude[0] === attBefore + cfg.refuseAttitude);
check('an ignored request costs half of a refusal', (() => {
const st2 = makeFlatState({ civs: 3 });
Diplo.resolveRequest(RULES, st2, { kind: 'joinWar', from: 1, to: 0, target: 2, turn: 1 }, false, 0.5);
return Logic.frustrationOf(st2, 1, 0) === Math.round(cfg.refuseFrustration * 0.5);
})());
const before = Logic.frustrationOf(st, 1, 0);
Logic.updateAttitudes(RULES, st, 1);
check('frustration decays each turn',
Logic.frustrationOf(st, 1, 0) === before - RULES.diplomacy.frustrationDecay);
// Sustained frustration should pull attitude well below zero on its own.
st.civs[1].frustration[0] = 80;
for (let i = 0; i < 30; i += 1) {
st.civs[1].frustration[0] = 80; // hold it there against the decay
Logic.updateAttitudes(RULES, st, 1);
}
check('frustration drags attitude toward hostility', st.civs[1].attitude[0] <= -30,
`${st.civs[1].attitude[0]}`);
}
// --- cooldowns and the per-leader gap
{
const st = makeFlatState({ civs: 3 });
setWar(st, 1, 2);
Logic.applyTreaty(st, 0, 1, 'peace');
st.civs[1].attitude[0] = 60;
Diplo.resolveRequest(RULES, st, { kind: 'joinWar', from: 1, to: 0, target: 2, turn: st.turn }, false);
check('kind is on cooldown right after asking',
!Diplo.cooldownReady(RULES, st, 1, 0, 'joinWar'));
check('same kind not re-offered while on cooldown', (() => {
for (let i = 0; i < 200; i += 1) {
st.civs[1].lastRequestTurn = {}; // clear only the per-leader gap
const req = Diplo.considerRequest(RULES, st, 1, 0);
if (req && req.kind === 'joinWar') return false;
}
return true;
})());
check('a different leader is unaffected by the cooldown',
Diplo.cooldownReady(RULES, st, 2, 0, 'joinWar'));
st.turn += RULES.requestKinds.joinWar.cooldown;
check('cooldown expires on schedule', Diplo.cooldownReady(RULES, st, 1, 0, 'joinWar'));
// The per-leader gap throttles a leader across ALL kinds.
st.civs[1].lastRequestTurn = { 0: st.turn };
let asked = false;
for (let i = 0; i < 200; i += 1) if (Diplo.considerRequest(RULES, st, 1, 0)) asked = true;
check('per-leader gap silences a leader who just spoke', !asked);
st.turn += RULES.diplomacy.perLeaderGap;
check('leader speaks again once the gap has passed', (() => {
for (let i = 0; i < 200; i += 1) {
st.civs[1].requestCooldown = {};
if (Diplo.considerRequest(RULES, st, 1, 0)) return true;
}
return false;
})());
}
// --- frustrated leaders switch from favours to compensation demands
{
const st = makeFlatState({ civs: 3 });
setWar(st, 1, 2);
Logic.applyTreaty(st, 0, 1, 'peace');
st.civs[1].attitude[0] = 60;
st.civs[0].gold = 400;
st.civs[0].known.alphabet = true;
st.civs[1].frustration = { 0: RULES.diplomacy.frustrationDemandThreshold };
const kinds = collect(st, 1, 0);
check('a frustrated leader only demands compensation',
kinds.size > 0 && [...kinds].every((k) => k === 'demandGold' || k === 'demandTech'),
[...kinds].join(','));
const goldReq = { kind: 'demandGold', from: 1, to: 0, gold: 100, turn: st.turn };
const purse = st.civs[0].gold;
Diplo.resolveRequest(RULES, st, goldReq, true);
check('paying a demand moves the gold', st.civs[0].gold === purse - 100
&& st.civs[1].gold === 200);
check('paying a demand clears the grudge', Logic.frustrationOf(st, 1, 0) === 0);
st.civs[1].frustration[0] = 50;
Diplo.resolveRequest(RULES, st, { kind: 'demandTech', from: 1, to: 0, techId: 'alphabet', turn: st.turn }, true);
check('handing over a demanded tech transfers it', st.civs[1].known.alphabet === true);
}
// --- escalation: renounce the treaty, then let ordinary hostility take over
{
const st = makeFlatState({ civs: 2 });
Logic.applyTreaty(st, 0, 1, 'peace');
st.civs[1].frustration = { 0: RULES.diplomacy.frustrationBreakThreshold };
check('breaking point renounces the treaty', Diplo.escalateFrustration(RULES, st, 1, 0)
&& st.civs[1].relations[0] === 'contact');
check('renouncing is announced', st.events.some((e) => e.type === 'treatyRenounced' && e.from === 1));
check('renouncing vents some frustration',
Logic.frustrationOf(st, 1, 0) < RULES.diplomacy.frustrationBreakThreshold);
st.civs[1].frustration[0] = 100;
check('nothing left to renounce', !Diplo.escalateFrustration(RULES, st, 1, 0));
check('below the threshold nothing breaks', (() => {
const st2 = makeFlatState({ civs: 2 });
Logic.applyTreaty(st2, 0, 1, 'peace');
st2.civs[1].frustration = { 0: RULES.diplomacy.frustrationBreakThreshold - 1 };
return !Diplo.escalateFrustration(RULES, st2, 1, 0) && st2.civs[1].relations[0] === 'peace';
})());
}
// --- break-your-treaty-with-X. No war between 1 and 2 here, just loathing:
// that keeps joinWar (a higher-urgency tier) out of the way AND exercises
// the "hostile but not yet fighting" branch.
{
const st = makeFlatState({ civs: 3 });
Logic.applyTreaty(st, 0, 1, 'peace');
Logic.applyTreaty(st, 0, 2, 'peace');
st.civs[1].attitude[0] = 40;
st.civs[1].attitude[2] = -50;
check('breakTreaty offered against a rival you are friendly with',
collect(st, 1, 0).has('breakTreaty'));
check('accepting breaks the treaty with the third party',
Diplo.resolveRequest(RULES, st, { kind: 'breakTreaty', from: 1, to: 0, target: 2, turn: st.turn }, true)
&& st.civs[0].relations[2] === 'contact');
check('breakTreaty needs a treaty to break',
!collect(st, 1, 0).has('breakTreaty'));
}
// --- border ultimatum and the promise it creates
{
const st = makeFlatState({ civs: 2 });
const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 8, 8, null));
Logic.spawnUnit(RULES, st, 0, 'warriors', 9, 8, null);
check('one unit nearby is not an ultimatum', !collect(st, 1, 0).has('borderUltimatum'));
Logic.spawnUnit(RULES, st, 0, 'warriors', 10, 9, null);
check('massed units draw an ultimatum', collect(st, 1, 0).has('borderUltimatum'));
check('settlers alone do not count', (() => {
const st2 = makeFlatState({ civs: 2 });
Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 8, 8, null));
Logic.spawnUnit(RULES, st2, 0, 'settlers', 9, 8, null);
Logic.spawnUnit(RULES, st2, 0, 'settlers', 10, 9, null);
return !collect(st2, 1, 0).has('borderUltimatum');
})());
const req = { kind: 'borderUltimatum', from: 1, to: 0, cityId: city.id, cityName: city.name, turn: st.turn };
Diplo.resolveRequest(RULES, st, req, true);
check('accepting records a promise', !!st.civs[1].pledges[0]);
Diplo.checkPledges(RULES, st, 1);
check('the promise is not judged before it comes due', !!st.civs[1].pledges[0]);
st.turn += 20;
const cfg = RULES.requestKinds.borderUltimatum;
Diplo.checkPledges(RULES, st, 1);
check('a broken promise costs double a refusal',
Logic.frustrationOf(st, 1, 0) === cfg.refuseFrustration * 2);
check('a broken promise is announced', st.events.some((e) => e.type === 'pledgeBroken'));
// Keeping it: units gone by the time the pledge expires.
const st2 = makeFlatState({ civs: 2 });
const city2 = Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 8, 8, null));
Diplo.resolveRequest(RULES, st2, {
kind: 'borderUltimatum', from: 1, to: 0, cityId: city2.id, cityName: city2.name, turn: st2.turn,
}, true);
st2.turn += 20;
const att = st2.civs[1].attitude[0];
Diplo.checkPledges(RULES, st2, 1);
check('a kept promise is rewarded', st2.civs[1].attitude[0] > att
&& st2.events.some((e) => e.type === 'pledgeKept'));
check('the promise is cleared either way', !st2.civs[1].pledges[0]);
}
// --- tech trades and unprompted gifts
{
const st = makeFlatState({ civs: 2 });
st.civs[0].known.pottery = true;
st.civs[1].known.alphabet = true;
st.civs[1].attitude[0] = 20;
check('techTrade offered when both sides have something', collect(st, 1, 0).has('techTrade'));
check('techTrade swaps both ways', Diplo.resolveRequest(RULES, st, {
kind: 'techTrade', from: 1, to: 0, giveId: 'alphabet', getId: 'pottery', turn: st.turn,
}, true) && st.civs[0].known.alphabet === true && st.civs[1].known.pottery === true);
const stG = makeFlatState({ civs: 2 });
Logic.applyTreaty(stG, 0, 1, 'peace');
stG.civs[1].attitude[0] = 90;
stG.civs[1].gold = 300;
stG.civs[1].known.alphabet = true;
check('a devoted leader sends an unprompted gift', Diplo.considerGift(RULES, stG, 1, 0)
&& stG.events.some((e) => e.type === 'aiGift' && e.to === 0));
check('gifts have their own long cooldown', !Diplo.considerGift(RULES, stG, 1, 0));
check('an indifferent leader sends nothing', (() => {
const st2 = makeFlatState({ civs: 2 });
Logic.applyTreaty(st2, 0, 1, 'peace');
st2.civs[1].attitude[0] = 20;
st2.civs[1].gold = 300;
return !Diplo.considerGift(RULES, st2, 1, 0);
})());
}
// --- staleness: the world moves on between the AI round and the audience
{
const st = makeFlatState({ civs: 3 });
setWar(st, 1, 2);
Logic.applyTreaty(st, 0, 1, 'peace');
const req = { kind: 'joinWar', from: 1, to: 0, target: 2, turn: st.turn };
check('a live request validates', Diplo.requestValid(RULES, st, req));
st.civs[2].alive = false;
check('a request against a dead civ is dropped', !Diplo.requestValid(RULES, st, req));
st.civs[0].gold = 30;
check('a demand you can no longer afford is dropped',
!Diplo.requestValid(RULES, st, { kind: 'demandGold', from: 1, to: 0, gold: 100, turn: st.turn }));
st.civs[0].known.alphabet = true;
st.civs[1].known.alphabet = true;
check('a demand for a tech they now have is dropped',
!Diplo.requestValid(RULES, st, { kind: 'demandTech', from: 1, to: 0, techId: 'alphabet', turn: st.turn }));
}
// --- data integrity: every kind is tunable and has something to say
{
let complete = true;
let missing = '';
for (const kind of Diplo.REQUEST_KINDS) {
const ok = !!RULES.requestKinds[kind]
&& Array.isArray(Chat.REQUESTS[kind]) && Chat.REQUESTS[kind].length >= 2
&& Chat.REQUEST_REPLIES[kind]?.accept?.length >= 2
&& Chat.REQUEST_REPLIES[kind]?.refuse?.length >= 2;
if (!ok) { complete = false; missing += `${kind} `; }
}
check('every request kind has rules + chat lines', complete, missing);
check('request lines fill every token they use', (() => {
const st = makeFlatState({ civs: 3 });
const reqs = [
{ kind: 'joinWar', from: 1, to: 0, target: 2 },
{ kind: 'breakTreaty', from: 1, to: 0, target: 2 },
{ kind: 'borderUltimatum', from: 1, to: 0, cityName: 'Testopolis' },
{ kind: 'demandGold', from: 1, to: 0, gold: 100 },
{ kind: 'demandTech', from: 1, to: 0, techId: 'alphabet' },
{ kind: 'techTrade', from: 1, to: 0, giveId: 'alphabet', getId: 'pottery' },
{ kind: 'ceasefire', from: 1, to: 0 }, { kind: 'peace', from: 1, to: 0 },
{ kind: 'alliance', from: 1, to: 0 },
];
for (const req of reqs) {
const vars = { you: 'Civ0', me: 'Civ1', ...Diplo.requestVars(RULES, st, req) };
for (const pool of [Chat.REQUESTS[req.kind],
Chat.REQUEST_REPLIES[req.kind].accept, Chat.REQUEST_REPLIES[req.kind].refuse]) {
for (const template of pool) {
if (/\{\w+\}/.test(Chat.pickLine([template], vars))) return false;
}
}
}
return true;
})());
check('consequence hints exist for the costly kinds', (() => {
const st = makeFlatState({ civs: 3 });
Logic.applyTreaty(st, 0, 2, 'peace');
st.civs[0].known.alphabet = true;
return ['joinWar', 'breakTreaty', 'demandGold', 'demandTech'].every((kind) => {
const req = {
kind, from: 1, to: 0, target: 2, gold: 50, techId: 'alphabet',
};
const hint = Diplo.requestConsequence(RULES, st, req);
return typeof hint === 'string' && hint.length > 10 && !/\{|undefined|NaN/.test(hint);
});
})());
}
// --- serialization
{
const st = makeFlatState({ civs: 2 });
st.civs[1].frustration = { 0: 42 };
st.civs[1].requestCooldown = { 0: { joinWar: 60 } };
st.civs[1].pledges = { 0: { kind: 'withdraw', cityId: 3, untilTurn: 9 } };
st.pendingRequests = [{ kind: 'joinWar', from: 1, to: 0, target: 0, turn: 1 }];
const back = Logic.deserialize(Logic.serialize(st));
check('frustration survives a save', back.civs[1].frustration[0] === 42);
check('cooldowns survive a save', back.civs[1].requestCooldown[0].joinWar === 60);
check('pledges survive a save', back.civs[1].pledges[0].untilTurn === 9);
check('pending requests survive a save', back.pendingRequests.length === 1
&& back.pendingRequests[0].kind === 'joinWar');
}
// --- the whole arc, driven by real AI turns: an ally who is refused over
// and over stops asking favours, starts demanding compensation, tears up the
// treaty, and finally declares war. This is the feature's headline behaviour,
// and it is easy to tune it into never firing (the grudge decays between
// requests), so pin it down.
{
const leaders = ['steve', 'gerome', 'jerry'].map((id) => ({ id, name: id[0].toUpperCase() + id.slice(1) }));
const play = (policy) => {
const st = Logic.createGame(RULES, { sizeId: 'small', seed: 77, difficultyId: 'prince', leaders, humanIndex: 0 });
const setRel = (a, b, r) => { st.civs[a].relations[b] = r; st.civs[b].relations[a] = r; };
setRel(0, 1, 'alliance');
setRel(0, 2, 'peace');
setRel(1, 2, 'war');
st.civs[1].attitude[0] = 45;
st.civs[0].gold = 2000;
st.civs[0].known.alphabet = true;
const log = { kinds: new Set(), renounced: false, war: false };
for (let t = 0; t < 300 && !st.over; t += 1) {
for (let c = 0; c < st.civs.length; c += 1) {
if (!st.civs[c].alive) continue;
st.current = c;
Logic.beginCivTurn(RULES, st, c);
if (c !== 0) AI.runAITurn(RULES, st, c);
Logic.endCivTurn(RULES, st, c);
}
// Stand in for the scene: answer one audience per human turn.
const req = (st.pendingRequests ?? []).shift();
if (req && Diplo.requestValid(RULES, st, req)) {
if (req.from === 1) log.kinds.add(req.kind);
Diplo.resolveRequest(RULES, st, req, policy === 'accept');
}
for (const e of st.events) {
if (e.type === 'treatyRenounced' && e.from === 1) log.renounced = true;
if (e.type === 'war' && e.a === 1 && e.b === 0) log.war = true;
}
}
return { st, log };
};
const refused = play('refuse');
check('a spurned ally asks for favours first', refused.log.kinds.has('joinWar'));
check('refusals eventually turn into compensation demands',
refused.log.kinds.has('demandGold') || refused.log.kinds.has('demandTech'),
[...refused.log.kinds].join(','));
check('a leader pushed far enough renounces the treaty', refused.log.renounced);
check('and follows it to war', refused.log.war
|| refused.st.civs[1].relations[0] === 'war');
const obliged = play('accept');
check('an obliging partner is never driven to demands',
!obliged.log.kinds.has('demandGold') && !obliged.log.kinds.has('demandTech'),
[...obliged.log.kinds].join(','));
check('an obliging partner keeps the alliance', obliged.st.civs[1].relations[0] === 'alliance'
&& !obliged.log.renounced);
}
// --- fuzz: issuing and resolving requests never corrupts the diplomatic state
{
const st = makeFlatState({ civs: 4 });
st.rngState = 909090;
st.civs.forEach((c) => { c.human = false; });
for (const c of st.civs) { c.gold = 200; c.known.alphabet = c.id % 2 === 0; c.known.pottery = c.id % 2 === 1; }
const legalStates = new Set(['nocontact', 'contact', 'war', 'ceasefire', 'peace', 'alliance']);
let ok = true;
let why = '';
const rounds = QUICK ? 200 : 800;
for (let i = 0; i < rounds && ok; i += 1) {
const a = Logic.randInt(st, 4);
let b = Logic.randInt(st, 4);
if (a === b) b = (b + 1) % 4;
const roll = Logic.rand(st);
if (roll < 0.15) Logic.declareWar(RULES, st, a, b);
else if (roll < 0.3) Logic.applyTreaty(st, a, b, ['ceasefire', 'peace', 'alliance'][Logic.randInt(st, 3)]);
else if (roll < 0.4) Diplo.escalateFrustration(RULES, st, a, b);
else if (roll < 0.5) Diplo.checkPledges(RULES, st, a);
else if (roll < 0.6) Logic.updateAttitudes(RULES, st, a);
else if (roll < 0.7) { st.turn += 1; Diplo.considerGift(RULES, st, a, b); } else {
const req = Diplo.considerRequest(RULES, st, a, b);
if (req) Diplo.resolveRequest(RULES, st, req, Diplo.aiWouldAccept(RULES, st, req));
}
for (const civ of st.civs) {
for (const [other, rel] of Object.entries(civ.relations)) {
if (!legalStates.has(rel)) { ok = false; why = `illegal relation ${rel}`; }
if (st.civs[other].relations[civ.id] !== rel) { ok = false; why = 'asymmetric relations'; }
}
for (const v of Object.values(civ.frustration ?? {})) {
if (!(v >= 0 && v <= 100)) { ok = false; why = `frustration ${v}`; }
}
for (const v of Object.values(civ.attitude)) {
if (!(v >= -100 && v <= 100)) { ok = false; why = `attitude ${v}`; }
}
if (civ.gold < 0) { ok = false; why = 'negative gold'; }
}
}
check('fuzz: request traffic keeps diplomacy consistent', ok, why);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
section('7. spaceship'); section('7. spaceship');