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:
parent
2880eeab2a
commit
4fcae92e59
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
|
|
@ -296,6 +296,39 @@
|
|||
"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": [
|
||||
{ "until": -1000, "step": 50 },
|
||||
{ "until": 1, "step": 25 },
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ export const MANIFEST = {
|
|||
tetrisattack: [
|
||||
{ type: 'json', key: 'tetrisattack', path: 'data/tetrisattack.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']),
|
||||
// Stage Clear backgrounds (background-{hero}-r{stage}.png, 6 friends × 5
|
||||
// stages) and character voice clips (assets/fx/tetrisattack/{hero}-*.mp3)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
//
|
||||
// runAITurn(rules, state, civIdx) plays one civ's whole turn: strategy pick,
|
||||
// diplomacy, research, city builds, then unit orders. respondToProposal
|
||||
// answers human (or other-AI) diplomacy. AI-initiated proposals toward the
|
||||
// human are queued on state.events as { type: 'aiProposal', keep: true } for
|
||||
// the scene to present at the start of the human turn.
|
||||
// answers human (or other-AI) diplomacy. AI-initiated approaches toward the
|
||||
// human (treaty proposals, favours, demands) are queued on
|
||||
// state.pendingRequests for the scene to present as an audience at the start
|
||||
// of the human turn — see CivilizationDiplomacy.js.
|
||||
|
||||
import {
|
||||
rand, randInt, cheb, tileIndex, inBounds, terrainAt, cityAt, unitsAt,
|
||||
|
|
@ -12,10 +13,14 @@ import {
|
|||
availableBuildings, canFoundCity, foundCity, setBuild, buyCost, buyBuild,
|
||||
tryMove, disembark, findPath, startWork, canWork, canEstablishRoute,
|
||||
establishTradeRoute, resolveAttack, attackerStrength, defenderStrength,
|
||||
pickDefender, launchSpaceship, canPropose, applyTreaty, declareWar,
|
||||
pickDefender, launchSpaceship, canPropose, declareWar,
|
||||
exchangeTechs, civPower, cityYields, buildCost,
|
||||
setResearch, startRevolution,
|
||||
} from './CivilizationLogic.js';
|
||||
import {
|
||||
considerRequest, considerGift, makeTreatyRequest, resolveRequest, aiWouldAccept,
|
||||
escalateFrustration, checkPledges, pickFairTrade, setCooldown,
|
||||
} from './CivilizationDiplomacy.js';
|
||||
import { siteQuality } from './CivilizationWorldGen.js';
|
||||
|
||||
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) {
|
||||
const civ = state.civs[civIdx];
|
||||
checkPledges(rules, state, civIdx);
|
||||
for (const other of state.civs) {
|
||||
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];
|
||||
if (rel === 'nocontact') continue;
|
||||
const attitude = civ.attitude[other.id] ?? 0;
|
||||
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)) {
|
||||
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) {
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const target = state.civs[toIdx];
|
||||
if (target.human) {
|
||||
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);
|
||||
}
|
||||
const req = makeTreatyRequest(rules, state, fromIdx, toIdx, kind);
|
||||
if (req) issueRequest(rules, state, req);
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
const trade = pickFairTrade(rules, state, a, b);
|
||||
if (trade) exchangeTechs(rules, state, a, b, trade.giveId, trade.getId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -4,9 +4,15 @@
|
|||
// {you} — the player's leader name (the person being spoken to)
|
||||
// {me} — the opponent leader's name
|
||||
// {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.
|
||||
|
||||
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)];
|
||||
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.',
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 : '',
|
||||
};
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
|
|||
import { compileRules, turnToYear, formatYear } from './CivilizationRules.js';
|
||||
import * as Logic from './CivilizationLogic.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 { openCityScreen } from './CivilizationCityScreen.js';
|
||||
import {
|
||||
|
|
@ -1260,7 +1262,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
} else if (needsPick) {
|
||||
this.openTech();
|
||||
}
|
||||
this.presentAIProposals();
|
||||
this.presentAIRequests();
|
||||
this.announceCityAttacks();
|
||||
this.announceEvents();
|
||||
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`);
|
||||
} else if (e.type === 'spaceshipLaunched') {
|
||||
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)) {
|
||||
const other = e.a === human ? e.b : e.a;
|
||||
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 proposals = this.state.events.filter((e) => e.type === 'aiProposal' && e.to === human);
|
||||
this.state.events = this.state.events.filter((e) => !(e.type === 'aiProposal' && e.to === human));
|
||||
const next = () => {
|
||||
const p = proposals.shift();
|
||||
if (!p) { this.showNextStatus(); return; }
|
||||
if (!Logic.canPropose(this.state, p.from, human, p.kind)) { next(); return; }
|
||||
const from = this.state.civs[p.from];
|
||||
this.confirmDialog(
|
||||
`${from.name} proposes a ${p.kind === 'ceasefire' ? 'cease-fire' : p.kind}. Accept?`,
|
||||
() => { Logic.applyTreaty(this.state, p.from, human, p.kind); this.refreshHud(); next(); },
|
||||
() => next(),
|
||||
);
|
||||
};
|
||||
next();
|
||||
const tuning = this.rules.diplomacy ?? {};
|
||||
const queue = this.state.pendingRequests ?? [];
|
||||
// Drop requests the world has invalidated, and lapse the stale ones.
|
||||
this.state.pendingRequests = queue.filter((req) => {
|
||||
if (!requestValid(this.rules, this.state, req)) return false;
|
||||
if (this.state.turn - req.turn >= (tuning.lapseTurns ?? 3)) {
|
||||
resolveRequest(this.rules, this.state, req, false, 0.5);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// announceStatus owns the queue (and kicks it when nothing else is open),
|
||||
// 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) {
|
||||
|
|
@ -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;
|
||||
this.modalOpen = true;
|
||||
openDiplomacyScreen(this, this.rules, this.state, this.opponentsData, respondToProposal, () => {
|
||||
|
|
@ -1592,7 +1642,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
this.view.refresh();
|
||||
this.refreshHud();
|
||||
this.showNextStatus();
|
||||
}, focusCivId, playIntro);
|
||||
}, { focusCivId, playIntro, request, onResolved });
|
||||
}
|
||||
|
||||
openSpaceship() {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ export function createGame(rules, opts) {
|
|||
explored: [],
|
||||
over: null,
|
||||
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);
|
||||
|
|
@ -111,6 +115,17 @@ export function createGame(rules, opts) {
|
|||
futureCount: 0,
|
||||
relations,
|
||||
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,
|
||||
spaceship: { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 },
|
||||
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)); }
|
||||
|
||||
// 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) {
|
||||
let power = 0;
|
||||
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] === 'alliance') baseline += 30;
|
||||
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);
|
||||
const cur = civ.attitude[other.id] ?? 0;
|
||||
civ.attitude[other.id] = clampAttitude(cur + Math.sign(baseline - cur) * Math.min(3, Math.abs(baseline - cur)));
|
||||
|
|
|
|||
|
|
@ -102,6 +102,38 @@ export function compileRules(json) {
|
|||
}
|
||||
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
|
||||
// matters" verify check)
|
||||
const gates = {};
|
||||
|
|
@ -133,6 +165,8 @@ export function compileRules(json) {
|
|||
civTraitList: json.civTraits,
|
||||
improvementList: json.improvements ?? [],
|
||||
worldSizeList: json.worldSizes ?? [],
|
||||
diplomacy,
|
||||
requestKinds,
|
||||
techRank: rank,
|
||||
techGates: gates,
|
||||
specialsByTerrain,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import { createOpponentPortrait } from '../../ui/Portrait.js';
|
|||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import * as Logic from './CivilizationLogic.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 ERAS = ['ancient', 'medieval', 'industrial', 'modern'];
|
||||
|
|
@ -116,9 +119,15 @@ export function openTechScreen(scene, rules, state, onClose) {
|
|||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
focusCivId = null, playIntroOnFocus = false) {
|
||||
{ focusCivId = null, playIntro: playIntroOnFocus = false, request = null, onResolved = null } = {}) {
|
||||
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];
|
||||
let portrait = 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
|
||||
// is reserved before the reveal starts — no reflow while typing.
|
||||
function pushChat(who, text) {
|
||||
if (!text) return; // pickLine returns '' for a missing pool — no empty bubbles
|
||||
const hist = historyFor(selected.id);
|
||||
hist.push({ who, text });
|
||||
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 };
|
||||
|
||||
// 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];
|
||||
detail.add(scene.add.text(cx, top + 330,
|
||||
`${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,
|
||||
}).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.
|
||||
const actions = [];
|
||||
if (rel === 'war') actions.push(['PROPOSE CEASE-FIRE', () => propose('ceasefire')]);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,17 @@ function dim(scene, alpha = 0.72) {
|
|||
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 = {}) {
|
||||
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);
|
||||
|
|
@ -41,16 +52,10 @@ function textButton(scene, x, y, label, action, opts = {}) {
|
|||
// ── Mode menu ────────────────────────────────────────────────────────────────
|
||||
export function showMenu(scene) {
|
||||
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;
|
||||
|
||||
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 = [
|
||||
{ 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() },
|
||||
|
|
@ -124,7 +129,7 @@ export function showPuzzleSelect(scene) {
|
|||
export function showStageSelect(scene) {
|
||||
scene.clearOverlay();
|
||||
scene.teardownGameplay();
|
||||
dim(scene, 0.92);
|
||||
menuBackdrop(scene, 0.62);
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const rounds = scene.rounds;
|
||||
const perRound = scene.stagesPerRound;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import { compileRules, techCost, turnToYear } from '../src/games/civilization/Ci
|
|||
import { generateWorld, siteQuality, shieldGrassAt } from '../src/games/civilization/CivilizationWorldGen.js';
|
||||
import * as Logic from '../src/games/civilization/CivilizationLogic.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 root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
|
@ -1263,6 +1265,456 @@ if (RULES) {
|
|||
&& 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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue