diff --git a/assets/images/tetrisattack/main-menu.png b/assets/images/tetrisattack/main-menu.png new file mode 100644 index 0000000..5ff7b8d Binary files /dev/null and b/assets/images/tetrisattack/main-menu.png differ diff --git a/data/civilization-rules.json b/data/civilization-rules.json index 30c9c56..1e8bad5 100644 --- a/data/civilization-rules.json +++ b/data/civilization-rules.json @@ -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 }, diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 0d2630d..e598d44 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -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) diff --git a/src/games/civilization/CivilizationAI.js b/src/games/civilization/CivilizationAI.js index 8f78284..c52e05c 100644 --- a/src/games/civilization/CivilizationAI.js +++ b/src/games/civilization/CivilizationAI.js @@ -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); } // --------------------------------------------------------------------------- diff --git a/src/games/civilization/CivilizationChat.js b/src/games/civilization/CivilizationChat.js index 1b94565..4eafe99 100644 --- a/src/games/civilization/CivilizationChat.js +++ b/src/games/civilization/CivilizationChat.js @@ -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.', +]; diff --git a/src/games/civilization/CivilizationDiplomacy.js b/src/games/civilization/CivilizationDiplomacy.js new file mode 100644 index 0000000..dbe0dc1 --- /dev/null +++ b/src/games/civilization/CivilizationDiplomacy.js @@ -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 : '', + }; +} diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index 4e2839c..6ad9e02 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -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() { diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index 3190311..c268f57 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -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))); diff --git a/src/games/civilization/CivilizationRules.js b/src/games/civilization/CivilizationRules.js index 7691eea..da1e0b0 100644 --- a/src/games/civilization/CivilizationRules.js +++ b/src/games/civilization/CivilizationRules.js @@ -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, diff --git a/src/games/civilization/CivilizationScreens.js b/src/games/civilization/CivilizationScreens.js index 8055e98..ff4c4ed 100644 --- a/src/games/civilization/CivilizationScreens.js +++ b/src/games/civilization/CivilizationScreens.js @@ -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')]); diff --git a/src/games/tetrisattack/TetrisAttackScreens.js b/src/games/tetrisattack/TetrisAttackScreens.js index 33e4c2c..dc45080 100644 --- a/src/games/tetrisattack/TetrisAttackScreens.js +++ b/src/games/tetrisattack/TetrisAttackScreens.js @@ -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; diff --git a/tools/verifyCivilization.js b/tools/verifyCivilization.js index 53a00f9..031495e 100644 --- a/tools/verifyCivilization.js +++ b/tools/verifyCivilization.js @@ -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');