diff --git a/data/mastervega-rules.json b/data/mastervega-rules.json index bf24feb..9a1c44a 100644 --- a/data/mastervega-rules.json +++ b/data/mastervega-rules.json @@ -433,6 +433,12 @@ "attitudeThreshold": 45, "aiProposeChance": 0.25 }, + "warRequest": { + "_readme": "\"Please make peace with them\" — a request about a THIRD PARTY's war (VegaDiplomacy.js's wouldAcceptPeaceRequest/requestPeace/offerPeaceRequest). friendThreshold is how much the one being asked has to like the requester to grant it as a favor when they wouldn't otherwise; refuseBelowAttitude is how bad their own war with the third party can be before even a friend's plea is waved off. aiProposeChance is per-friend-per-turn, checked only once the human is already at war with that friend.", + "friendThreshold": 40, + "refuseBelowAttitude": -70, + "aiProposeChance": 0.15 + }, "espionage": { "_readme": "Sabotage magnitude when a runEspionage roll succeeds with mission 'sabotage' (VegaLogic.js). Targeting/frequency/odds are unchanged from the steal-tech path; this only controls what a sabotage success does. A successful mission (sabotage or tech theft) carries no attitude penalty — nobody publicly knows who did it, so there's nobody for the victim to be angry at; getting CAUGHT is the one outcome that still costs relations.", "sabotageFactoriesFraction": 0.25, diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index f4c130f..d8e76ee 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -29,7 +29,7 @@ import { openSystemView } from './VegaSystemView.js'; import { openCombatViewV2 } from './VegaCombatViewV2.js'; import { FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen, - openSaveScreen, openLoadScreen, showVictoryOverlay, + openSaveScreen, openLoadScreen, showVictoryOverlay, openFormationPicker, } from './VegaScreens.js'; import { openColoniesScreen } from './VegaColoniesScreen.js'; import { openResearchScreen } from './VegaResearchScreen.js'; @@ -1167,23 +1167,29 @@ export default class MasterOfVegaGame extends Phaser.Scene { const next = (i) => { if (i >= pending.length) { this.refreshAll(); done(); return; } const { starIdx, other } = pending[i]; - const prepared = Logic.prepareBattleAt(this.rules, this.state, starIdx, me, other); - if (!prepared) { next(i + 1); return; } this.map?.panToStar(starIdx, 260); this.modalOpen = true; - this.music?.setCategory('combat'); - openCombatViewV2(this, this.rules, prepared.battle, this.art, { - attackerSpecies: this.state.empires[prepared.attackerIdx].speciesId, - defenderSpecies: this.state.empires[prepared.defenderIdx].speciesId, - playerSide: prepared.attackerIdx === me ? 'attacker' : 'defender', - onDone: (result) => { - Logic.applyBattleOutcome(this.rules, this.state, prepared, result); - this.modalOpen = false; - this.music?.setCategory('peace'); - this.refreshAll(); - next(i + 1); - }, - }); + // Forced (closable: false) — there's no sensible "cancel" once fleets + // are already committed to this fight. Only the human's own side is + // ever asked; the AI opponent always picks silently (Logic + // .prepareBattleAt's own comment). + openFormationPicker(this, (humanFormation) => { + const prepared = Logic.prepareBattleAt(this.rules, this.state, starIdx, me, other, { humanFormation }); + if (!prepared) { this.modalOpen = false; next(i + 1); return; } + this.music?.setCategory('combat'); + openCombatViewV2(this, this.rules, prepared.battle, this.art, { + attackerSpecies: this.state.empires[prepared.attackerIdx].speciesId, + defenderSpecies: this.state.empires[prepared.defenderIdx].speciesId, + playerSide: prepared.attackerIdx === me ? 'attacker' : 'defender', + onDone: (result) => { + Logic.applyBattleOutcome(this.rules, this.state, prepared, result); + this.modalOpen = false; + this.music?.setCategory('peace'); + this.refreshAll(); + next(i + 1); + }, + }); + }, { closable: false }); }; next(0); } diff --git a/src/games/mastervega/VegaAudience.js b/src/games/mastervega/VegaAudience.js index f731026..dc669ed 100644 --- a/src/games/mastervega/VegaAudience.js +++ b/src/games/mastervega/VegaAudience.js @@ -45,6 +45,7 @@ import { import { atWar, grantTech } from './VegaLogic.js'; import { attitudeOf, moodOf, videoMood, canNegotiate, declareWar, proposeTreaty, respondToOffer, tradeTech, giveGift, + requestPeace, } from './VegaDiplomacy.js'; import { CHAT, pickLine } from './VegaChat.js'; @@ -535,10 +536,14 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo } const openingOffer = state.empires[me].pendingOffers[otherIdx]; if (openingOffer) { - const key = openingOffer.kind === 'peace' ? chat.offerPeaceOpener - : openingOffer.kind === 'alliance' ? chat.offerAllianceOpener - : chat.offerTradeAgreementOpener; - pushChat('o', pickLine(key, vars)); + if (openingOffer.kind === 'peaceRequest') { + pushChat('o', pickLine(chat.offerPeaceRequestOpener, { ...vars, third: state.empires[openingOffer.thirdParty]?.name })); + } else { + const key = openingOffer.kind === 'peace' ? chat.offerPeaceOpener + : openingOffer.kind === 'alliance' ? chat.offerAllianceOpener + : chat.offerTradeAgreementOpener; + pushChat('o', pickLine(key, vars)); + } } // A fresh (this-turn-or-last) fleet complaint gets its own opener line, @@ -568,9 +573,29 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo } function doPending(accept) { - pushChat('p', pickLine(accept ? chat.acceptOffer : chat.rejectOffer, vars)); + const offer = state.empires[me].pendingOffers[otherIdx]; + const isPeaceRequest = offer?.kind === 'peaceRequest'; + const thirdVars = isPeaceRequest ? { ...vars, third: state.empires[offer.thirdParty]?.name } : vars; + pushChat('p', pickLine( + isPeaceRequest ? (accept ? chat.acceptPeaceRequest : chat.rejectPeaceRequest) + : (accept ? chat.acceptOffer : chat.rejectOffer), + thirdVars, + )); respondToOffer(rules, state, me, otherIdx, accept); - say(pickLine(accept ? chat.afterAccepted : chat.afterRejected, vars)); + if (isPeaceRequest) { + // respondToOffer's own true/false means "was there an offer to + // answer," not "did the treaty succeed" (existing callers depend on + // that contract) — the actual outcome is read back off the treaty + // state instead, only when accepted. + const madePeace = accept && !atWar(state, me, offer.thirdParty); + say(pickLine( + !accept ? chat.afterPeaceRequestRejected + : madePeace ? chat.afterPeaceRequestAcceptedSuccess : chat.afterPeaceRequestAcceptedTried, + thirdVars, + )); + } else { + say(pickLine(accept ? chat.afterAccepted : chat.afterRejected, thirdVars)); + } afterAction(); } @@ -628,6 +653,18 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo afterAction(); } + // Asking `otherIdx` to end a war with a THIRD empire — see + // VegaDiplomacy.js's requestPeace for why this is kept separate from + // proposeTreaty/wouldAccept (it's a favor about someone else's war, not a + // treaty between the two people actually talking). + function doRequestPeace(thirdPartyIdx) { + const reqVars = { ...vars, third: state.empires[thirdPartyIdx].name }; + pushChat('p', pickLine(chat.requestPeaceThird, reqVars)); + const accepted = requestPeace(rules, state, me, otherIdx, thirdPartyIdx); + say(pickLine(accepted ? chat.replyRequestPeaceAccept : chat.replyRequestPeaceReject, reqVars)); + afterAction(); + } + function doTechTrade(giveId, wantId) { pushChat('p', pickLine(chat.tradeTechOffer, { ...vars, tech: rules.techs[wantId].name })); const ok = tradeTech(rules, state, me, otherIdx, giveId, wantId); @@ -668,7 +705,10 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo say(pickLine(chat.storyLore, vars)); } - function pickFromList(title, ids, onPick) { + // `labelFn` defaults to the tech-name lookup every existing caller wants; + // doRequestWhichWar (below) is the one caller that isn't picking a tech, + // so it passes an empire-name lookup instead. + function pickFromList(title, ids, onPick, labelFn = (id) => rules.techs[id].name) { const box = scene.add.container(0, 0); root.add(box); const bx = GAME_WIDTH / 2; @@ -678,7 +718,7 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo box.add(scene.add.rectangle(bx, by, 420, h, PANEL, 0.98).setStrokeStyle(2, ACCENT)); box.add(scene.add.text(bx, by - h / 2 + 26, title, { fontFamily: FONT, fontSize: '19px', color: '#ffd88a' }).setOrigin(0.5)); ids.slice(0, 12).forEach((id, i) => { - const t = scene.add.text(bx, by - h / 2 + 66 + i * 34, rules.techs[id].name, { + const t = scene.add.text(bx, by - h / 2 + 66 + i * 34, labelFn(id), { fontFamily: FONT, fontSize: '16px', color: '#e8f4ff', }).setOrigin(0.5).setInteractive({ useHandCursor: true }); t.on('pointerover', () => t.setColor('#ffd88a')); @@ -691,6 +731,12 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo box.add(cancel); } + /** Opens the "which war?" picker for Demand Peace, listing every empire + * `otherIdx` is at war with (that a request could ever succeed against). */ + function openWarPicker(warTargets) { + pickFromList('END WHICH WAR?', warTargets, doRequestPeace, (idx) => state.empires[idx].name); + } + function openTechPicker(giveOptions, wantOptions) { pickFromList('OFFER WHICH TECHNOLOGY?', giveOptions, (giveId) => { pickFromList('ASK FOR WHICH TECHNOLOGY?', wantOptions, (wantId) => doTechTrade(giveId, wantId)); @@ -768,6 +814,14 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo const want = Object.keys(state.empires[otherIdx].known).filter((t) => !state.empires[me].known[t]); if (give.length && want.length) items.push(['Trade Tech', () => openTechPicker(give, want)]); if (giftAffordable) items.push(['Send Gift', openGiftPicker]); + // Wars otherIdx is fighting against a THIRD empire — one the request + // could ever actually reach (canNegotiate both ways), never our own + // war with them (that's Sue for Peace, above). + const theirWars = state.empires + .filter((o) => o.alive && o.idx !== me && o.idx !== otherIdx + && atWar(state, otherIdx, o.idx) && canNegotiate(rules, state, otherIdx, o.idx)) + .map((o) => o.idx); + if (theirWars.length) items.push(['Demand Peace', () => openWarPicker(theirWars)]); } const treatyRows = layoutRow(items, BTN_Y); let nextY = BTN_Y + treatyRows * (BTN_H + BTN_GAP); diff --git a/src/games/mastervega/VegaChat.js b/src/games/mastervega/VegaChat.js index 0ccffba..8d84f50 100644 --- a/src/games/mastervega/VegaChat.js +++ b/src/games/mastervega/VegaChat.js @@ -13,6 +13,8 @@ // {you} — the human empire's display name // {me} — this species' empire name // {tech} — a technology name, for the tech-trade situations +// {third} — a third empire's display name, for the "make peace with them" +// request situations // Resolved (already-substituted) text is what VegaAudience.js pushes into // state.chatLog, not the templates. // @@ -36,6 +38,23 @@ // offer from this species. // afterAccepted / afterRejected — this species' reaction to the player's // answer to their own offer. +// requestPeaceThird / replyRequestPeaceAccept / replyRequestPeaceReject — +// the PLAYER asking THIS species to make peace with a third empire +// (VegaDiplomacy.js's requestPeace), and their reply. Uses a third +// token, {third}, alongside the usual {me}/{you}. +// offerPeaceRequestOpener — this species' own opening line when THEY ask +// the PLAYER to make peace with a third empire (offerPeaceRequest, +// held exactly like offerPeaceOpener/offerAllianceOpener above). +// acceptPeaceRequest / rejectPeaceRequest — the PLAYER's line answering +// that request (same Accept/Reject buttons a treaty offer uses — +// respondToOffer's 'peaceRequest' branch is what makes accepting mean +// "I'll propose peace to the third party," not a treaty with the asker). +// afterPeaceRequestAcceptedSuccess / afterPeaceRequestAcceptedTried — +// this species' reaction once the player agreed to try: Success if the +// third party actually accepted the resulting peace proposal, Tried if +// the third party turned it down anyway (not the player's fault). +// afterPeaceRequestRejected — this species' reaction to the player +// declining to even try. // // Additionally, three pure-flavor "conversation" topics, present for every // diplomacy-capable species, with ZERO effect on attitude — gating lives in @@ -189,6 +208,42 @@ export const CHAT = { 'I\'ve got a standing proposal for you, {you} — a trade agreement, ongoing, good for us both.', 'Let\'s formalize something, {you}: regular trade, on the record.', ], + requestPeaceThird: [ + 'Do us both a favor, {me} — make peace with {third}. A war on your border is bad for trade on mine.', + 'I\'d rather see you and {third} at peace, {me}. Fighting\'s bad for everyone\'s bottom line.', + ], + replyRequestPeaceAccept: [ + 'You\'ve got a point, {you}. I\'ll open talks with {third}.', + 'Fine, {you} — for you, I\'ll make the offer to {third}.', + ], + replyRequestPeaceReject: [ + 'Not this time, {you}. My war with {third} isn\'t yours to settle.', + 'I appreciate the thought, {you}, but {third} hasn\'t earned peace yet.', + ], + offerPeaceRequestOpener: [ + 'A favor, {you}: make peace with {third}. It\'s bad business watching a friend bleed money on a war.', + 'I\'m asking you to end things with {third}, {you}. Consider it a personal request.', + ], + acceptPeaceRequest: [ + 'Fair enough, {me}. I\'ll make the offer to {third}.', + 'For you, {me} — I\'ll try to settle things with {third}.', + ], + rejectPeaceRequest: [ + 'Not this time, {me}. That war is mine to end, on my terms.', + 'I\'ll pass, {me}. {third} hasn\'t earned it from me yet.', + ], + afterPeaceRequestAcceptedSuccess: [ + 'Good business, {you}. Peace with {third} suits everyone\'s ledger.', + 'Well done, {you} — {third} and I can both stop paying for this war now.', + ], + afterPeaceRequestAcceptedTried: [ + 'Appreciated, {you}, even if {third} wasn\'t ready to listen.', + 'You tried, {you} — that\'s worth something, even though {third} said no.', + ], + afterPeaceRequestRejected: [ + 'Disappointing, {you}. I thought we understood each other better.', + 'Noted, {you}. I won\'t forget you turned down a simple favor.', + ], fleetComplaintOpener: [ 'Your fleet\'s been sitting over my colony a little too long, {you}. Care to explain, or just move it?', 'I\'ll be direct, {you}: that fleet parked over my world is bad for business. Move it.', @@ -357,6 +412,42 @@ export const CHAT = { 'The aerie proposes something lasting, {you} — a standing trade agreement, not merely a single exchange.', 'Let us formalize our commerce, {you}. I offer an ongoing arrangement.', ], + requestPeaceThird: [ + 'Fold your wings toward {third}, {me}. This war does not become you.', + 'I ask a favor of the aerie, {me}: make peace with {third}.', + ], + replyRequestPeaceAccept: [ + 'For you, {you}, we will offer {third} our terms.', + 'A reasonable request. The aerie will approach {third}.', + ], + replyRequestPeaceReject: [ + 'No, {you}. {third} has not yet earned our mercy.', + 'That quarrel is ours to end, {you}, not yours to arrange.', + ], + offerPeaceRequestOpener: [ + 'A request, {you}: make your peace with {third}. It troubles the aerie to watch a friend circle so low.', + 'I ask this of you, {you} — end your war with {third}. Do it for us, if not for yourself.', + ], + acceptPeaceRequest: [ + 'Since you ask it, {me}, I will approach {third}.', + 'For the aerie\'s sake, {me}, I will try.', + ], + rejectPeaceRequest: [ + 'No, {me}. That sky is mine to clear.', + 'I decline, {me}. {third} has not yet fallen far enough.', + ], + afterPeaceRequestAcceptedSuccess: [ + 'Well flown, {you}. The aerie rests easier with {third} at peace.', + 'Gracefully done, {you}. Two wars end today.', + ], + afterPeaceRequestAcceptedTried: [ + 'You tried, {you}, even if {third}\'s wings proved too proud to land.', + 'The effort is noted, {you}, though {third} was not yet ready to listen.', + ], + afterPeaceRequestRejected: [ + 'A groundling\'s answer, {you}. The aerie remembers who refuses a simple favor.', + 'Then the sky between us grows a little colder, {you}.', + ], fleetComplaintOpener: [ 'Your ships circle our nest longer than courtesy allows, {you}. Explain yourself, or withdraw them.', 'A fleet that lingers over our aerie is no longer a guest, {you}. Move it.', @@ -524,6 +615,42 @@ export const CHAT = { 'We offer something that holds, {you} — not a single exchange, but a standing arrangement.', 'Let our trade stand steady, {you}. We propose an agreement.', ], + requestPeaceThird: [ + 'Stop fighting {third}, {me}. Enough blood spilled there.', + 'Make peace with {third}, {me}. I ask it plainly.', + ], + replyRequestPeaceAccept: [ + 'Fine, {you}. I will offer {third} an end to it.', + 'For you, {you} — I\'ll put down the axe against {third}.', + ], + replyRequestPeaceReject: [ + 'No, {you}. That fight is not finished.', + '{third} has not bled enough yet, {you}. Not your call to make.', + ], + offerPeaceRequestOpener: [ + 'Make peace with {third}, {you}. I ask it as one who stands beside you.', + 'End it with {third}, {you}. A war you carry hurts us both.', + ], + acceptPeaceRequest: [ + 'For you, {me}, I\'ll offer {third} peace.', + 'Fine, {me}. I\'ll try with {third}.', + ], + rejectPeaceRequest: [ + 'No, {me}. {third} has not earned it.', + 'That fight stays mine, {me}.', + ], + afterPeaceRequestAcceptedSuccess: [ + 'Good. {third} and you, at peace — stronger for it, {you}.', + 'Well done, {you}. One less war weighs on us both.', + ], + afterPeaceRequestAcceptedTried: [ + 'You tried, {you}. {third} refused — not your failing.', + 'Effort noted, {you}, even though {third} would not yield.', + ], + afterPeaceRequestRejected: [ + 'Weak answer, {you}. I will remember it.', + 'Then we remember, {you}, who refuses a simple ask.', + ], fleetComplaintOpener: [ 'Your ships sit over our ground too long, {you}. Explain the weight of that, or lift it.', 'A fleet that does not move becomes an occupation, {you}. Move it.', @@ -692,6 +819,42 @@ export const CHAT = { 'We propose something lasting, {you} — a trade agreement. Sincerely, for whatever that\'s worth from us.', 'A standing arrangement, {you}. We\'d like to make this formal, believe it or not.', ], + requestPeaceThird: [ + 'Wear a kinder face for {third}, {me}. Make peace.', + 'I ask you to end it with {third}, {me} — even we tire of watching, sometimes.', + ], + replyRequestPeaceAccept: [ + 'How generous of me, {you}. I\'ll offer {third} peace — this once.', + 'Very well, {you}. I\'ll play the peacemaker with {third}. It\'s a good look on me.', + ], + replyRequestPeaceReject: [ + 'No, {you}. That mask doesn\'t suit me yet.', + 'I\'ll keep the war with {third}, {you}. It amuses me still.', + ], + offerPeaceRequestOpener: [ + 'A strange request from me, {you}, I know — make peace with {third}. Even we grow tired of some faces.', + 'I ask a favor, {you}: end your war with {third}. Trust me, if you can manage it.', + ], + acceptPeaceRequest: [ + 'If it pleases you, {me}, I\'ll offer {third} peace.', + 'Fine, {me}. I\'ll wear the face of a peacemaker with {third}.', + ], + rejectPeaceRequest: [ + 'No, {me}. I like the war with {third} exactly as it is.', + 'I\'ll decline, {me}. Not every mask fits.', + ], + afterPeaceRequestAcceptedSuccess: [ + 'How refreshing, {you} — peace with {third}, and you actually meant it.', + 'Well played, {you}. {third} never saw it coming, and neither did I.', + ], + afterPeaceRequestAcceptedTried: [ + 'You tried, {you}. {third} wasn\'t buying it — I almost respect that.', + 'A convincing effort, {you}, even if {third} saw through it.', + ], + afterPeaceRequestRejected: [ + 'How predictable, {you}. I\'ll remember the face you showed me.', + 'Refused, {you}? I\'ll wear that against you later.', + ], fleetComplaintOpener: [ 'Your fleet has worn out its welcome over our world, {you}. Move it, or explain why it stays.', 'We\'ve watched your ships linger long enough, {you}. That\'s not a face we appreciate.', @@ -860,6 +1023,42 @@ export const CHAT = { 'Continuous exchange protocol calculated as favorable, {you}. Proposed.', 'Standing trade function available, {you}. Proposing.', ], + requestPeaceThird: [ + 'Cease hostilities with {third}. We request it.', + 'Terminate conflict-state with {third}, {me}. Request logged.', + ], + replyRequestPeaceAccept: [ + 'Request accepted. Peace-protocol will be offered to {third}.', + 'Acknowledged. We will approach {third}.', + ], + replyRequestPeaceReject: [ + 'Request denied. Conflict-state with {third} continues.', + 'Negative. {third} has not met termination-conditions.', + ], + offerPeaceRequestOpener: [ + 'Request: terminate your conflict-state with {third}. Reason: mutual inefficiency.', + 'We require this of you: cease hostilities with {third}.', + ], + acceptPeaceRequest: [ + 'Acknowledged. Peace-protocol will be offered to {third}.', + 'Request accepted. We will attempt termination with {third}.', + ], + rejectPeaceRequest: [ + 'Request denied. Conflict-state with {third} continues.', + 'Negative. Termination not authorized.', + ], + afterPeaceRequestAcceptedSuccess: [ + 'Termination confirmed. Efficiency restored.', + 'Conflict-state with {third} resolved. Function approves.', + ], + afterPeaceRequestAcceptedTried: [ + 'Attempt logged. {third} rejected termination. Compliance noted regardless.', + 'Request processed. Outcome with {third} negative. Effort acknowledged.', + ], + afterPeaceRequestRejected: [ + 'Request denial logged. Cooperation-rating reduced.', + 'Non-compliance recorded.', + ], fleetComplaintOpener: [ 'Foreign vessel detected over colony function beyond acceptable duration, {you}. Withdraw it.', 'Occupation pattern logged at colony coordinates, {you}. Explain function or withdraw.', @@ -1027,6 +1226,42 @@ export const CHAT = { 'A recurring exchange has been calculated as favorable, {you}. Proposing a standing agreement.', 'Continuous trade improves both systems, {you}. Proposal offered.', ], + requestPeaceThird: [ + 'Decommission your conflict with {third}, {me}. It is an inefficient allocation of resources.', + 'End the war with {third}, {me}. I recommend it as an upgrade to your situation.', + ], + replyRequestPeaceAccept: [ + 'Optimization accepted, {you}. I will offer {third} peace.', + 'Logical. I will initiate peace with {third}, {you}.', + ], + replyRequestPeaceReject: [ + 'Rejected, {you}. The war with {third} is not yet obsolete.', + 'No. {third} has not yet reached the threshold for peace.', + ], + offerPeaceRequestOpener: [ + 'A recommendation, {you}: decommission your war with {third}. It is wasted output.', + 'I propose an upgrade to your foreign policy, {you} — peace with {third}.', + ], + acceptPeaceRequest: [ + 'Recommendation accepted, {me}. I will offer {third} peace.', + 'Logical. I will attempt peace with {third}.', + ], + rejectPeaceRequest: [ + 'Rejected, {me}. That conflict remains operational.', + 'No. The war with {third} has not reached obsolescence.', + ], + afterPeaceRequestAcceptedSuccess: [ + 'Optimization complete, {you}. {third} at peace — efficiency restored.', + 'Confirmed. Your foreign policy is now upgraded, {you}.', + ], + afterPeaceRequestAcceptedTried: [ + 'Attempt logged, {you}. {third} rejected the upgrade. Compliance noted.', + 'Process completed, output negative — {third} declined. Effort registered.', + ], + afterPeaceRequestRejected: [ + 'Rejection logged, {you}. Cooperation index lowered.', + 'Non-compliance noted, {you}. Recalculating trust.', + ], fleetComplaintOpener: [ 'Your fleet occupies orbital space above my colony beyond acceptable parameters, {you}. Relocate it.', 'Unauthorized presence logged at my colony, {you}. Justify it or withdraw.', @@ -1194,6 +1429,42 @@ export const CHAT = { 'I offer something that holds, {you} — not one trade, but a standing arrangement.', 'Let this go beyond a single exchange, {you}. A trade agreement, proposed.', ], + requestPeaceThird: [ + 'Sheathe your claws against {third}, {me}. I ask it.', + 'End the hunt with {third}, {me}. Enough blood.', + ], + replyRequestPeaceAccept: [ + 'Fine, {you}. I\'ll offer {third} its life.', + 'For you, {you} — I\'ll call off the hunt against {third}.', + ], + replyRequestPeaceReject: [ + 'No, {you}. {third} has not yet earned mercy.', + 'That hunt is mine, {you}. Not yours to end.', + ], + offerPeaceRequestOpener: [ + 'Sheathe your claws against {third}, {you}. I ask this of you.', + 'End your hunt with {third}, {you}. Do it, and I\'ll remember it.', + ], + acceptPeaceRequest: [ + 'Fine, {me}. I\'ll offer {third} its life.', + 'For you, {me} — I\'ll call off my hunt.', + ], + rejectPeaceRequest: [ + 'No, {me}. {third} has not earned mercy.', + 'That hunt is mine to finish.', + ], + afterPeaceRequestAcceptedSuccess: [ + 'Good. {third} lives, and you\'ve proven you keep your word, {you}.', + 'Well done, {you}. A clean kill would have been simpler — this was harder.', + ], + afterPeaceRequestAcceptedTried: [ + 'You bared your throat and asked, {you}. {third} refused. Not your failure.', + 'You tried, {you}. {third} wasn\'t ready to yield. I respect the attempt.', + ], + afterPeaceRequestRejected: [ + 'A coward\'s answer, {you}. I\'ll remember it.', + 'Refused? Then remember I asked, {you}.', + ], fleetComplaintOpener: [ 'Your ships have circled my territory too long, {you}. Move them, or give me a reason not to treat them as prey.', 'A fleet that lingers this long isn\'t visiting, {you}. It\'s hunting. Explain, or withdraw.', @@ -1361,6 +1632,42 @@ export const CHAT = { 'I have modeled the benefit of a continuous arrangement, {you}. It exceeds a single exchange. Proposed.', 'A logical next step, {you}: a standing trade agreement, rather than isolated trades.', ], + requestPeaceThird: [ + 'The optimal solution, {me}, is peace with {third}. I recommend you see it.', + 'Consider the equation, {me}: your war with {third} solves nothing. End it.', + ], + replyRequestPeaceAccept: [ + 'A logical request, {you}. I will offer {third} the same solution.', + 'Correctly reasoned, {you}. I will approach {third}.', + ], + replyRequestPeaceReject: [ + 'An incomplete argument, {you}. The variables with {third} have not yet resolved.', + 'No, {you}. The equation is not yet solvable.', + ], + offerPeaceRequestOpener: [ + 'A simple proof, {you}: your war with {third} has no winning solution. End it.', + 'I offer you the answer, {you} — make peace with {third}. The logic is plain.', + ], + acceptPeaceRequest: [ + 'The proof holds, {me}. I will offer {third} peace.', + 'Correctly reasoned, {me}. I will attempt it.', + ], + rejectPeaceRequest: [ + 'An incomplete proof, {me}. I decline.', + 'No, {me}. The variables have not resolved.', + ], + afterPeaceRequestAcceptedSuccess: [ + 'Solved elegantly, {you}. {third} at peace — the correct answer, reached.', + 'As predicted, {you}. The logical outcome, achieved.', + ], + afterPeaceRequestAcceptedTried: [ + 'The proof was sound, {you}, even if {third} could not follow it. Noted regardless.', + 'You reasoned correctly, {you}. {third} simply lacked the capacity to agree.', + ], + afterPeaceRequestRejected: [ + 'A flawed conclusion, {you}. I will remember the error.', + 'Disappointing, {you}. I expected better logic from you.', + ], fleetComplaintOpener: [ 'Your fleet\'s continued presence over my colony no longer registers as accidental, {you}. Explain, or withdraw it.', 'I have recalculated your intentions given the duration of that fleet\'s presence, {you}, and the results are not favorable. Move it.', @@ -1528,6 +1835,42 @@ export const CHAT = { 'The brood proposes something lasting, {you} — a standing trade agreement, not a single exchange.', 'Let our clutches trade as neighbors do, {you}. We offer an ongoing arrangement.', ], + requestPeaceThird: [ + 'Withdraw your claws from {third}\'s brood, {me}. Make peace.', + 'I ask you to end your war with {third}, {me}, for the good of both clutches.', + ], + replyRequestPeaceAccept: [ + 'For you, {you}, I will offer {third}\'s brood peace.', + 'Very well, {you}. I will spare {third}\'s clutch, this once.', + ], + replyRequestPeaceReject: [ + 'No, {you}. {third}\'s brood has not yet paid enough.', + 'That territory is not yet settled, {you}. Not yours to decide.', + ], + offerPeaceRequestOpener: [ + 'A request from one clutch to another, {you}: make peace with {third}.', + 'I ask you to spare {third}\'s brood, {you}, and end this war.', + ], + acceptPeaceRequest: [ + 'For the clutch\'s sake, {me}, I will offer {third} peace.', + 'Very well, {me}. I will spare {third}, this once.', + ], + rejectPeaceRequest: [ + 'No, {me}. {third}\'s brood has not paid enough yet.', + 'That territory remains contested, {me}.', + ], + afterPeaceRequestAcceptedSuccess: [ + 'Good. {third}\'s brood lives, and our clutches both grow stronger for it, {you}.', + 'Wisely done, {you}. Peace with {third} serves every brood better than war did.', + ], + afterPeaceRequestAcceptedTried: [ + 'You asked on our behalf, {you}. {third} refused — the fault is theirs, not yours.', + 'The attempt is noted, {you}, though {third}\'s brood was not yet ready to yield.', + ], + afterPeaceRequestRejected: [ + 'A poor answer, {you}. The clutch remembers who refuses a simple request.', + 'Then remember, {you}, that we asked, and you said no.', + ], fleetComplaintOpener: [ 'Your fleet crowds our territory too long, {you}. The brood does not tolerate lingering guests. Withdraw it.', 'That fleet sits over our clutch longer than courtesy allows, {you}. Explain yourself, or move it.', diff --git a/src/games/mastervega/VegaCombatSim.js b/src/games/mastervega/VegaCombatSim.js index 8851de0..2a0bf3e 100644 --- a/src/games/mastervega/VegaCombatSim.js +++ b/src/games/mastervega/VegaCombatSim.js @@ -30,9 +30,9 @@ import { createBattle, runBattle } from './VegaCombat.js'; import { openCombatView } from './VegaCombatView.js'; import * as CombatV2 from './VegaCombatV2.js'; import { openCombatViewV2 } from './VegaCombatViewV2.js'; -import { FORMATION_STRATEGIES, formationName } from './VegaFormations.js'; +import { formationName } from './VegaFormations.js'; import { VegaMusic } from './VegaMusic.js'; -import { FONT, uiClick, modalShell } from './VegaScreens.js'; +import { FONT, uiClick, openFormationPicker } from './VegaScreens.js'; const PANEL_BG = 0x0b1220; const PANEL_LINE = 0x2c435f; @@ -238,7 +238,7 @@ export default class VegaCombatSim extends Phaser.Scene { // strategy. The defender ("AI") gets no prompt at all — it picks // silently, which CombatV2.createBattle already does on its own for // any side that doesn't supply a valid choice. - this.openFormationPicker((formationStrategy) => this.launchV2Battle(formationStrategy)); + openFormationPicker(this, (formationStrategy) => this.launchV2Battle(formationStrategy)); return; } const { attacker, defender, colony } = this.buildBattleOpts(); @@ -269,38 +269,6 @@ export default class VegaCombatSim extends Phaser.Scene { }); } - // A simple two-card picker: click a strategy to commit it immediately and - // start the battle (no separate confirm step — this is a "real quick" dev - // tool, not a game screen). Shown fresh every Watch Battle press in V2 - // mode; nothing is remembered between battles. - openFormationPicker(onPick) { - const shell = modalShell(this, 'Choose Formation Strategy', null, { width: 980, height: 420 }); - const { body } = shell; - shell.add(this.add.text(body.x + body.w / 2, body.y, 'Your fleet’s tactical doctrine for this battle. The enemy fleet picks its own, silently.', { - fontFamily: FONT, fontSize: '16px', color: '#6f8aa8', align: 'center', - }).setOrigin(0.5, 0)); - - const cardW = (body.w - 40) / 2; - const cardY = body.y + body.h / 2 + 20; - FORMATION_STRATEGIES.forEach((f, i) => { - const cx = body.x + cardW / 2 + i * (cardW + 40); - const card = this.add.rectangle(cx, cardY, cardW, 160, PANEL_BG, 0.7) - .setStrokeStyle(2, PANEL_LINE, 0.9); - shell.add(card); - shell.add(this.add.text(cx, cardY - 48, f.name, { - fontFamily: FONT, fontSize: '22px', color: '#cfe8ff', - }).setOrigin(0.5)); - shell.add(this.add.text(cx, cardY - 4, f.desc, { - fontFamily: FONT, fontSize: '15px', color: '#9fb6cc', align: 'center', wordWrap: { width: cardW - 40 }, - }).setOrigin(0.5, 0)); - const btn = new Button(this, cx, cardY + 55, 'Choose', uiClick(this, () => { - shell.destroy(); - onPick(f.id); - }), { width: 160, height: 44, fontSize: 16 }); - shell.add(btn); - }); - } - runTrials(n) { if (!this.battleReady()) return; const { attacker, defender, colony } = this.buildBattleOpts(); diff --git a/src/games/mastervega/VegaCombatV2.js b/src/games/mastervega/VegaCombatV2.js index fc3fafc..b56c9e4 100644 --- a/src/games/mastervega/VegaCombatV2.js +++ b/src/games/mastervega/VegaCombatV2.js @@ -448,10 +448,9 @@ function layoutFormation(rules, ships) { // frame closely, and a big one spans proportionally more of the (now much // bigger) world. // Returns the layout too (not just a side effect on `ships`) — createBattle -// uses `defenderEndX` to anchor a defended planet just past the defender's -// own fleet, rather than at a fixed world-edge position that would now sit -// far off in empty space, disconnected from a fleet placed near the world's -// center. +// reads `centerY` to keep a defended planet vertically level with the +// fleets (the planet's own X is fixed independently of this layout — see +// createBattle's own comment on why). // // The proportional gap is clamped to never push the total span past the // world's own width. Without this, a large mixed-hull fleet (many ships, @@ -576,10 +575,15 @@ export function createBattle(rules, opts) { const layout = placeFleets(rules, aShips, dShips); // A defended colony fights as an extra immobile entity that cannot be - // boarded — killing it is what clears the way for an invasion. Anchored - // just past the defender fleet's own footprint (not a fixed world-edge - // position) so it stays visually coherent with where the defender's - // ships actually ended up, and inside the camera's initial framing. + // boarded — killing it is what clears the way for an invasion. Fixed at + // the midpoint between the world's centre and the defender-side edge + // (Brian: "about equal distance from the edge of the screen and the + // center on its side") rather than anchored off the defender fleet's own + // footprint — a formation-relative anchor put the planet at very + // different depths depending on fleet size/spread, which read as the + // planet drifting around from battle to battle instead of holding a + // stable "home world" position. Defender is always placed on the +X side + // (placeFleets), so worldWidth is that side's edge. let planet = null; if (colony && colony.defenseHp > 0) { planet = { @@ -588,10 +592,11 @@ export function createBattle(rules, opts) { side: 'defender', isPlanet: true, name: 'Planetary Defences', + typeId: colony.typeId, hp: colony.defenseHp, hpMax: colony.defenseHp, shield: colony.shieldBonus ?? 0, - x: layout.defenderEndX + 220, + x: C2.worldWidth * 0.75, y: layout.centerY, facing: Math.PI, angularVelocity: 0, diff --git a/src/games/mastervega/VegaCombatViewV2.js b/src/games/mastervega/VegaCombatViewV2.js index ab734e4..056c050 100644 --- a/src/games/mastervega/VegaCombatViewV2.js +++ b/src/games/mastervega/VegaCombatViewV2.js @@ -27,7 +27,7 @@ import VegaFx from './VegaFx.js'; import { advance, runBattle, battleResult, shipBounds, SIM_DT, } from './VegaCombatV2.js'; -import { shipFrame } from './VegaArt.js'; +import { shipFrame, planetFrame } from './VegaArt.js'; import { buildParallax, bindZoomPan } from './VegaCombatCamera.js'; import { formationName } from './VegaFormations.js'; import { makeCommanderPortrait } from './VegaShipMedia.js'; @@ -36,6 +36,11 @@ import { makeCommanderPortrait } from './VegaShipMedia.js'; // multiplied by the firing ship's hull.sizeScale instead. const SHIP_BASE_SIZE = 58; +// A defended planet reads as bigger than any single ship (even a maxed-out +// battleship at SHIP_BASE_SIZE * 2.5 ≈ 145) without dominating the frame. +const PLANET_MARKER_SIZE = 110; +const SHIELD_RING_COLOR = 0x4fd8ff; + // Fire events now arrive as a steady trickle (each ship's own ~2s cooldown, // staggered by whenever it entered range) rather than one big per-round // batch, so every event gets both its visual FX AND its sound cue — no @@ -343,13 +348,25 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) { markers.clear(); for (const s of battle.ships) { const c = scene.add.container(s.x, s.y); + let shieldRing = null; if (s.isPlanet) { - const g = scene.add.graphics(); - g.fillStyle(0x8a6a3a, 1); - g.fillCircle(0, 0, 26); - g.lineStyle(2, 0xffd88a, 0.8); - g.strokeCircle(0, 0, 32); - c.add(g); + const img = scene.add.image(0, 0, art.planets, planetFrame(rules, s.typeId)) + .setDisplaySize(PLANET_MARKER_SIZE, PLANET_MARKER_SIZE); + c.add(img); + // A planetary shield's magnitude (from the Planetary Shield building, + // rules.buildings.planetaryshield's shieldBonus effect) is fixed for + // the whole battle — only WHETHER it's still up changes, tracked by + // toggling this ring's visibility in syncMarkers as the planet's own + // hp falls, rather than redrawing it every frame. + if (s.shield > 0) { + shieldRing = scene.add.graphics(); + const r = PLANET_MARKER_SIZE / 2 + 14; + shieldRing.fillStyle(SHIELD_RING_COLOR, 0.18); + shieldRing.fillCircle(0, 0, r); + shieldRing.lineStyle(2.5, SHIELD_RING_COLOR, 0.75); + shieldRing.strokeCircle(0, 0, r); + c.add(shieldRing); + } } else { const species = s.side === 'attacker' ? attackerSpecies : defenderSpecies; const size = SHIP_BASE_SIZE * (s.design.hull.sizeScale ?? 1); @@ -367,7 +384,7 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) { c.add(img); } shipLayer.add(c); - markers.set(s.uid, { ship: s, container: c }); + markers.set(s.uid, { ship: s, container: c, shieldRing }); } refreshSummaries(); } @@ -383,6 +400,9 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) { m.container.setAlpha(alive ? 1 : 0.25); m.container.setPosition(m.ship.x, m.ship.y); if (!m.ship.isPlanet) m.container.setRotation(m.ship.facing + Math.PI / 2); + // Hide the shield the instant the planet's own hp is gone — "unless + // it's destroyed" (Brian's ask). + m.shieldRing?.setVisible(m.ship.hp > 0); } if (aliveCount !== lastSummaryAlive) { lastSummaryAlive = aliveCount; diff --git a/src/games/mastervega/VegaDiplomacy.js b/src/games/mastervega/VegaDiplomacy.js index e28b489..2bbeb46 100644 --- a/src/games/mastervega/VegaDiplomacy.js +++ b/src/games/mastervega/VegaDiplomacy.js @@ -139,6 +139,61 @@ export function proposeTreaty(rules, state, a, b, kind) { return true; } +// -------------------------------------------------------------------------- +// "Please make peace with them" — a request about a THIRD PARTY's war, not a +// bilateral treaty between the two empires actually negotiating. Kept +// separate from wouldAccept/proposeTreaty/proposeOrOffer above since their +// shape (an offer resolves into a treaty between exactly the two parties +// talking) doesn't fit a favor that's about someone else's relationship. +// Only ever runs in one of two directions — the player asking an AI +// (requestPeace, resolved immediately, same as every other player-initiated +// audience action) or an AI asking the human (offerPeaceRequest, always +// held — mirrors proposeOrOffer's own reasoning: resolving it unilaterally +// would give the Audience screen's Accept/Reject buttons nothing to decide). +// AI-to-AI never happens; nothing in this file needs it, so it's not built. + +// Would `target` agree to make peace with `thirdParty`, asked by `requester`? +// Baseline is whatever `target` would decide on the war's own merits (the +// same read wouldAccept('peace') already uses) — a friend's request tips the +// balance for a target that WOULDN'T otherwise sue for peace, but only if +// they like the requester enough and the war isn't going so badly that even +// a friend's plea gets waved off. +export function wouldAcceptPeaceRequest(rules, state, requester, target, thirdParty) { + if (!atWar(state, target, thirdParty)) return false; + if (!canNegotiate(rules, state, target, thirdParty)) return false; + if (wouldAccept(rules, state, thirdParty, target, 'peace')) return true; + const cfg = rules.diplomacy.warRequest; + return attitudeOf(state, target, requester) >= cfg.friendThreshold + && attitudeOf(state, target, thirdParty) > cfg.refuseBelowAttitude; +} + +// The player asking `target` (always an AI) to make peace with `thirdParty`. +// Refusal costs the same small sting proposeTreaty's own refusal already +// costs elsewhere in this file — being told no by someone you leaned on is a +// real, if minor, friction point. Success also warms `target` toward the +// player a little on top of makePeace's own bilateral bump: gratitude for +// having been asked nicely rather than just left to fight. +export function requestPeace(rules, state, requester, target, thirdParty) { + if (!wouldAcceptPeaceRequest(rules, state, requester, target, thirdParty)) { + adjust(state, target, requester, -3); + return false; + } + makePeace(rules, state, target, thirdParty); + adjust(state, target, requester, 6); + return true; +} + +// An AI (`a`) asking the HUMAN (`b`) to make peace with `thirdParty`. Held +// exactly like proposeOrOffer's human-targeted branch; answered through +// respondToOffer, which special-cases offer.kind === 'peaceRequest' since +// accepting it doesn't create a treaty between `a` and `b` at all. +export function offerPeaceRequest(rules, state, a, b, thirdParty) { + if (state.empires[b].pendingOffers[a]) return 'pending'; + state.empires[b].pendingOffers[a] = { kind: 'peaceRequest', thirdParty, turn: state.turn }; + state.events.push({ type: 'offerReceived', empire: a, other: b, kind: 'peaceRequest', turn: state.turn }); + return 'pending'; +} + // When an AI proposes peace or an alliance TO THE HUMAN, resolving it // unilaterally (like AI-vs-AI) would give the Audience screen's Accept/Reject // buttons nothing to actually decide. So a proposal aimed at the human is @@ -166,6 +221,21 @@ export function respondToOffer(rules, state, b, a, accept) { if (offer.kind === 'peace') makePeace(rules, state, a, b); else if (offer.kind === 'alliance') formAlliance(rules, state, a, b); else if (offer.kind === 'tradeAgreement') formTradeAgreement(rules, state, a, b); + else if (offer.kind === 'peaceRequest') { + // Agreeing means `b` (the human) commits to actually proposing peace to + // the third party — same proposeTreaty check a personal Sue for Peace + // visit would run, so this can still fail to land if the third party + // itself isn't willing. Either way `a` is pleased the human tried; + // more so if it actually worked. madePeace rides on the event (not the + // return value here — respondToOffer's own true/false already means + // "was there an offer to answer", not "did the treaty succeed", and + // existing callers depend on that). + const madePeace = proposeTreaty(rules, state, b, offer.thirdParty, 'peace'); + adjust(state, a, b, madePeace ? 15 : 4); + state.events.push({ + type: 'peaceRequestResolved', empire: b, other: a, thirdParty: offer.thirdParty, madePeace, turn: state.turn, + }); + } } else { adjust(state, a, b, -8); // the proposer stings at being refused adjust(state, b, a, -2); // refusing is itself a small friction cost @@ -334,6 +404,22 @@ export function runDiplomacyTurn(rules, state, e) { && rand(state) < allianceCfg.aiProposeChance) { proposeOrOffer(rules, state, e, other.idx, 'alliance'); } + + // Ask the human to make peace with a friend of ours, if the human is at + // war with them — a favor request about someone ELSE's war, not about + // e's own relationship with the human (see VegaDiplomacy.js's + // offerPeaceRequest, above). `other` here is guaranteed not at war with + // e (the atWar(e, other) branch above already `continue`d) and liked + // enough by e (`att`) to count as "a friend." + const warCfg = rules.diplomacy.warRequest; + if (other.idx !== state.humanIndex && emp.contacted[state.humanIndex] + && canNegotiate(rules, state, e, state.humanIndex) + && atWar(state, state.humanIndex, other.idx) + && att > warCfg.friendThreshold + && !human?.pendingOffers?.[e] + && rand(state) < warCfg.aiProposeChance) { + offerPeaceRequest(rules, state, e, state.humanIndex, other.idx); + } } } diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 871849b..2fcc45f 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -1334,7 +1334,13 @@ function applyBattleLosses(rules, state, starIdx, e, survivors) { // Split out of fightAt so the player can drive a battle tick by tick through // VegaCombatViewV2 and then hand the outcome back — the interactive battle // and auto-resolve therefore run the same engine and cannot diverge. -export function prepareBattleAt(rules, state, starIdx, a, b) { +// `humanFormation` (VegaFormations.js id) is stamped onto whichever side is +// state.humanIndex, so a human-participated battle can carry the tactic the +// player actually chose (MasterOfVegaGame.js's playPlayerBattles, via its +// pre-battle formation picker) instead of createBattle's own silent-random +// fallback for an unset side. AI-vs-AI battles (fightAt, below) never pass +// this and are unaffected — both sides stay silently random as before. +export function prepareBattleAt(rules, state, starIdx, a, b, { humanFormation = null } = {}) { const colony = colonyAt(state, starIdx); const defenderIdx = colony && (colony.empireIdx === a || colony.empireIdx === b) ? colony.empireIdx : b; const attackerIdx = defenderIdx === a ? b : a; @@ -1350,14 +1356,29 @@ export function prepareBattleAt(rules, state, starIdx, a, b) { }; const attacker = mkSide(attackerIdx); const defender = mkSide(defenderIdx); + if (humanFormation) { + if (attackerIdx === state.humanIndex) attacker.formationStrategy = humanFormation; + else if (defenderIdx === state.humanIndex) defender.formationStrategy = humanFormation; + } const defColony = colony && colony.empireIdx === defenderIdx ? colony : null; if (!attacker.ships.length) return null; if (!defender.ships.length && !(defColony && defColony.defenseHp > 0)) return null; + // The planet's typeId rides along purely for the tactical view's art (the + // real planets spritesheet instead of a generic icon) — the combat engine + // itself never reads it. shieldBonus was hard-coded to 0 here until now, + // which meant the Planetary Shield building's own effect (rules.buildings + // .planetaryshield's shieldBonus:5) was silently never applied to a real + // battle, only ever exercised through the standalone ?movsim simulator's + // manual stepper — found while wiring the shield ring into the view. const battle = createBattle(rules, { attacker, defender, - colony: defColony ? { defenseHp: defColony.defenseHp, shieldBonus: 0 } : null, + colony: defColony ? { + defenseHp: defColony.defenseHp, + shieldBonus: buildingEffect(rules, defColony, 'shieldBonus'), + typeId: state.galaxy.stars[starIdx]?.planets[defColony.orbit]?.typeId, + } : null, starIdx, rnd: () => rand(state), }); diff --git a/src/games/mastervega/VegaResearchScreen.js b/src/games/mastervega/VegaResearchScreen.js index d4352bb..8dd2994 100644 --- a/src/games/mastervega/VegaResearchScreen.js +++ b/src/games/mastervega/VegaResearchScreen.js @@ -217,7 +217,10 @@ export function openResearchScreen(scene, rules, state, e, art, onClose, { initi gridLayer.add(bg); const lvl = fieldTechLevel(rules, state, e, field.id); - gridLayer.add(scene.add.text(bx + 12, by + 8, `${field.name.toUpperCase()} · Lv ${lvl}`, { + const fieldIconSize = 26; + gridLayer.add(scene.add.image(bx + 12 + fieldIconSize / 2, by + 8 + 10, art.techfields, field.iconFrame) + .setDisplaySize(fieldIconSize, fieldIconSize)); + gridLayer.add(scene.add.text(bx + 12 + fieldIconSize + 8, by + 8, `${field.name.toUpperCase()} · Lv ${lvl}`, { fontFamily: FONT, fontSize: '18px', color: '#cfe8ff', })); @@ -312,7 +315,10 @@ export function openResearchScreen(scene, rules, state, e, art, onClose, { initi const field = rules.techFields[selectedField]; const detailLvl = fieldTechLevel(rules, state, e, selectedField); - detailLayer.add(scene.add.text(detailX, headingY, `${field.name.toUpperCase()} — TECH TREE (Level ${detailLvl})`, { + const detailIconSize = 22; + detailLayer.add(scene.add.image(detailX + detailIconSize / 2, headingY + 9, art.techfields, field.iconFrame) + .setDisplaySize(detailIconSize, detailIconSize)); + detailLayer.add(scene.add.text(detailX + detailIconSize + 8, headingY, `${field.name.toUpperCase()} — TECH TREE (Level ${detailLvl})`, { fontFamily: FONT, fontSize: '18px', color: '#cfe8ff', })); diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index 9e14f30..8544b29 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -18,6 +18,7 @@ import { leaderOffers } from './VegaLeaders.js'; import { makeSpeciesPortrait } from './VegaArt.js'; import { openAudienceScreen } from './VegaAudience.js'; +import { FORMATION_STRATEGIES } from './VegaFormations.js'; export const FONT = '"Julius Sans One"'; // `colony` sits above `modal` so the colony screen can stack over the system @@ -157,6 +158,47 @@ export function slider(scene, x, y, w, label, value, onChange, colour = ACCENT) }; } +// -------------------------------------------------------------------------- + +/** + * A simple two-card picker for the player's pre-battle formation strategy + * (VegaFormations.js). The opposing side always picks silently — an unset + * side falls back to VegaCombatV2.createBattle's own seeded-random choice — + * this screen only ever asks about the player's own side. Shared by + * VegaCombatSim.js's dev tool (closable, a "real quick" testing aid) and the + * real pre-battle flow (MasterOfVegaGame.js's playPlayerBattles, forced — + * `closable: false`, since there is no sensible "cancel" once fleets are + * already committed to a fight this turn). + */ +export function openFormationPicker(scene, onPick, { closable = true } = {}) { + const shell = modalShell(scene, 'Choose Formation Strategy', null, { width: 980, height: 420, closable }); + const { body } = shell; + shell.add(scene.add.text(body.x + body.w / 2, body.y, + "Your fleet's tactical doctrine for this battle. The enemy fleet picks its own, silently.", { + fontFamily: FONT, fontSize: '16px', color: '#8fa8c0', align: 'center', + }).setOrigin(0.5, 0)); + + const cardW = (body.w - 40) / 2; + const cardY = body.y + body.h / 2 + 20; + FORMATION_STRATEGIES.forEach((f, i) => { + const cx = body.x + cardW / 2 + i * (cardW + 40); + const card = scene.add.rectangle(cx, cardY, cardW, 160, PANEL, 0.7).setStrokeStyle(2, ACCENT, 0.9); + shell.add(card); + shell.add(scene.add.text(cx, cardY - 48, f.name, { + fontFamily: FONT, fontSize: '22px', color: '#cfe8ff', + }).setOrigin(0.5)); + shell.add(scene.add.text(cx, cardY - 4, f.desc, { + fontFamily: FONT, fontSize: '15px', color: '#9fb6cc', align: 'center', wordWrap: { width: cardW - 40 }, + }).setOrigin(0.5, 0)); + const btn = new Button(scene, cx, cardY + 55, 'Choose', uiClick(scene, () => { + shell.destroy(); + onPick(f.id); + }), { width: 160, height: 44, fontSize: 16 }); + shell.add(btn); + }); + return shell; +} + // -------------------------------------------------------------------------- // Research has its own file, VegaResearchScreen.js — it grew past this // module's shared factory-function pattern the same way Audience and the diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index fd6cefc..11c3661 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -2344,6 +2344,122 @@ section('7. Diplomacy and the Galactic Council'); st3.events.some((e) => e.type === 'offerExpired' && e.empire === 1 && e.other === 0)); } + // --- "make peace with them" third-party requests (VegaDiplomacy.js's + // wouldAcceptPeaceRequest/requestPeace/offerPeaceRequest) — a favor about + // someone ELSE's war, not a treaty between the two people actually + // talking. Brian's ask: the player can ask an AI to end a war with a + // third empire, an AI can ask the player the same, and refusing either + // direction costs the relationship. + { + const stP = Logic.createGame(RULES, { + sizeId: 'small', shapeId: 'cluster', seed: 7373, difficultyId: 'normal', + speciesIds: ['human', 'kkrix', 'ursaal'], humanIndex: 0, + }); + stP.rules = RULES; + const player = 0; + const target = 1; + const third = 2; + for (const e of stP.empires) { + e.totalPop = 100; + for (const o of stP.empires) if (o.idx !== e.idx) { e.contacted[o.idx] = true; e.attitude[o.idx] = 0; } + } + Diplo.declareWar(RULES, stP, target, third); + check('fixture: target is at war with third', Logic.atWar(stP, target, third)); + + stP.empires[target].attitude[third] = -50; // not losing badly, not devoted either + stP.empires[target].attitude[player] = 0; // not yet a friend + check('a stranger\'s request is refused when the target has no independent reason to accept', + !Diplo.wouldAcceptPeaceRequest(RULES, stP, player, target, third)); + + stP.empires[target].attitude[player] = 60; + check('a friend\'s request succeeds as a favor even when the target would not sue for peace unprompted', + Diplo.wouldAcceptPeaceRequest(RULES, stP, player, target, third)); + + stP.empires[target].attitude[third] = -90; + check('even a friend\'s request is refused when the target\'s own war attitude is bad enough', + !Diplo.wouldAcceptPeaceRequest(RULES, stP, player, target, third)); + + // requestPeace: success actually makes peace and warms target toward the + // requester beyond makePeace's own bilateral bump; failure stings instead. + stP.empires[target].attitude[third] = -50; + stP.empires[target].attitude[player] = 60; + const beforeAtt = Diplo.attitudeOf(stP, target, player); + const ok = Diplo.requestPeace(RULES, stP, player, target, third); + check('requestPeace succeeds when wouldAcceptPeaceRequest is true', ok === true); + check('a successful request actually makes peace', !Logic.atWar(stP, target, third)); + check('a successful request warms the target toward the requester', + Diplo.attitudeOf(stP, target, player) > beforeAtt); + + Diplo.declareWar(RULES, stP, target, third); // reset the war for the failure case + stP.empires[target].attitude[player] = -50; // no longer a friend + stP.empires[target].attitude[third] = -90; // and losing badly + const beforeAtt2 = Diplo.attitudeOf(stP, target, player); + const failed = Diplo.requestPeace(RULES, stP, player, target, third); + check('requestPeace fails when wouldAcceptPeaceRequest is false', failed === false); + check('a failed request still leaves them at war', Logic.atWar(stP, target, third)); + check('a failed request costs the requester a small sting', + Diplo.attitudeOf(stP, target, player) < beforeAtt2); + + // offerPeaceRequest: held exactly like proposeOrOffer's human-targeted + // branch, and respondToOffer's peaceRequest case resolves acceptance as + // "try to make peace with the third party," not a treaty with the asker. + Diplo.declareWar(RULES, stP, player, third); // now the HUMAN is at war with third + const heldReq = Diplo.offerPeaceRequest(RULES, stP, target, player, third); + check('offerPeaceRequest holds, same contract as proposeOrOffer', heldReq === 'pending'); + check('the held peaceRequest offer carries its thirdParty', + stP.empires[player].pendingOffers[target]?.thirdParty === third); + const heldAgain = Diplo.offerPeaceRequest(RULES, stP, target, player, third); + check('a second peaceRequest from the same asker does not clobber the first', heldAgain === 'pending'); + + stP.empires[third].attitude[player] = 50; // so the resulting proposeTreaty to third can land + const beforeAskerAtt = Diplo.attitudeOf(stP, target, player); + const respAccept = Diplo.respondToOffer(RULES, stP, player, target, true); + check('respondToOffer resolves a held peaceRequest (true, same contract as any offer)', + respAccept === true); + check('accepting a peaceRequest attempts peace with the third party', + !Logic.atWar(stP, player, third)); + check('accepting clears the pending offer', !stP.empires[player].pendingOffers[target]); + check('the asker is pleased the human tried', + Diplo.attitudeOf(stP, target, player) > beforeAskerAtt); + check('accepting a peaceRequest pushes a peaceRequestResolved event', + stP.events.some((e) => e.type === 'peaceRequestResolved' && e.empire === player && e.other === target + && e.thirdParty === third && e.madePeace === true)); + + // Rejecting reuses the exact generic refusal path every other offer kind + // already uses — refusing costs the relationship, Brian's explicit ask. + Diplo.declareWar(RULES, stP, player, third); + Diplo.offerPeaceRequest(RULES, stP, target, player, third); + const beforeReject = Diplo.attitudeOf(stP, target, player); + Diplo.respondToOffer(RULES, stP, player, target, false); + check('rejecting a peaceRequest costs the relationship, same as any other refused offer', + Diplo.attitudeOf(stP, target, player) < beforeReject); + check('rejecting a peaceRequest never touches the third party\'s treaty', + Logic.atWar(stP, player, third)); + + // AI-initiation: an AI that likes a friend enough, with the human at war + // with that friend, eventually offers the human a peaceRequest. + const stAI = Logic.createGame(RULES, { + sizeId: 'small', shapeId: 'cluster', seed: 8181, difficultyId: 'normal', + speciesIds: ['human', 'kkrix', 'ursaal'], humanIndex: 0, + }); + stAI.rules = RULES; + for (const e of stAI.empires) { + e.totalPop = 100; + for (const o of stAI.empires) if (o.idx !== e.idx) { e.contacted[o.idx] = true; e.attitude[o.idx] = 0; } + } + const asker = 1; + const friend = 2; + Diplo.declareWar(RULES, stAI, stAI.humanIndex, friend); + let anyOffered = false; + for (let i = 0; i < 100 && !anyOffered; i += 1) { + stAI.empires[asker].attitude[friend] = 80; // pin above friendThreshold — isolate the dice roll + Diplo.runDiplomacyTurn(RULES, stAI, asker); + anyOffered = !!stAI.empires[stAI.humanIndex].pendingOffers[asker]; + } + check('runDiplomacyTurn eventually offers the human a peaceRequest for a war against a liked empire', + anyOffered, `pendingOffers=${JSON.stringify(stAI.empires[stAI.humanIndex].pendingOffers)}`); + } + // --- colonize() must trigger contact even when nobody's fleet just moved. { const stC = Logic.createGame(RULES, { @@ -2714,6 +2830,9 @@ section('7. Diplomacy and the Galactic Council'); 'offerTradeAgreementOpener', 'fleetComplaintOpener', 'acknowledgeComplaintWithdraw', 'acknowledgeComplaintDefy', 'afterComplaintWithdrawPromise', 'afterComplaintDefy', + 'requestPeaceThird', 'replyRequestPeaceAccept', 'replyRequestPeaceReject', + 'offerPeaceRequestOpener', 'acceptPeaceRequest', 'rejectPeaceRequest', + 'afterPeaceRequestAcceptedSuccess', 'afterPeaceRequestAcceptedTried', 'afterPeaceRequestRejected', ]; // Lore reply pools are meant to carry real variety — enforce a minimum // beyond "non-empty" since pickLine's empty-pool fallback is silent and @@ -3454,10 +3573,11 @@ section('11. Combat V2 (per-ship prototype)'); { // Headless like everything else here — VegaCombatV2.js is Phaser-free, so // this whole section is a plain Node exercise, same pattern as section 5. - // This engine is NOT used by real player battles (wired only into - // VegaCombatSim.js's ?movsim Live/V2 toggle) — see - // docs/mastervega-build-plan.md for why it's a parallel prototype rather - // than a rewrite of VegaCombat.js in place. + // This IS the resolver real player battles use (VegaLogic.js imports + // createBattle/runBattle from here, not from VegaCombat.js — V1 is kept + // only for resolveInvasion and the ?movsim Live/V2 comparison toggle) — + // see docs/mastervega-build-plan.md for the history of why it started as + // a parallel prototype. const techsUpToV2 = (tier) => { const k = {}; for (const t of RULES.techList) if (t.tier <= tier) k[t.id] = true; @@ -3545,6 +3665,64 @@ section('11. Combat V2 (per-ship prototype)'); both1.attackerFormation === both2.attackerFormation && both1.defenderFormation === both2.defenderFormation); } + // Logic.prepareBattleAt — the real-game seam between the engine and a + // player battle. Scripted through a real state (not a hand-built fixture) + // since it reads fleets/colonies/buildings/galaxy data no synthetic object + // could cheaply fake correctly. Covers three things fixed together in one + // pass: the player's own pre-battle formation choice actually reaching the + // battle, the Planetary Shield building's shieldBonus actually reaching + // the defended planet (it was hard-coded to 0 before, so the building's + // effect was silently never applied to a real battle), and the planet's + // typeId riding along for the tactical view's real planet art. + { + const st = Logic.createGame(RULES, { + sizeId: 'small', shapeId: 'cluster', seed: 5151, difficultyId: 'normal', + speciesIds: ['human', 'kkrix'], humanIndex: 0, + }); + st.rules = RULES; + const attackerIdx = st.humanIndex; + const defenderIdx = attackerIdx === 0 ? 1 : 0; + const defColony = st.colonies.find((c) => c.empireIdx === defenderIdx); + const starIdx = defColony.starIdx; + defColony.defenseHp = 200; + defColony.buildings.push('planetaryshield'); + Diplo.declareWar(RULES, st, attackerIdx, defenderIdx); + Logic.addFleet(RULES, st, attackerIdx, starIdx, [{ hullId: 'frigate', mark: 1, count: 2 }]); + + const prepared = Logic.prepareBattleAt(RULES, st, starIdx, attackerIdx, defenderIdx, { humanFormation: 'speed_swarm' }); + check('prepareBattleAt finds a valid battle for the scripted attack-a-defended-colony scenario', !!prepared); + + const humanIsAttacker = prepared.attackerIdx === st.humanIndex; + const humanFormationOnBattle = humanIsAttacker ? prepared.battle.attackerFormation : prepared.battle.defenderFormation; + check("the human's chosen formation reaches battle.*Formation on the human's own side", + humanFormationOnBattle === 'speed_swarm', humanFormationOnBattle); + const validIds = FORMATION_STRATEGIES.map((f) => f.id); + const aiFormationOnBattle = humanIsAttacker ? prepared.battle.defenderFormation : prepared.battle.attackerFormation; + check("the AI opponent's formation is still a silent (but valid) pick, not forced", + validIds.includes(aiFormationOnBattle)); + + const planetEntity = prepared.battle.ships.find((s) => s.isPlanet); + check('the defended planet entity exists in the prepared battle', !!planetEntity); + check("the Planetary Shield building's shieldBonus (5) reaches the planet entity's shield — was hard-coded to 0 before", + planetEntity?.shield === RULES.buildings.planetaryshield.effects.shieldBonus, + `${planetEntity?.shield}`); + const expectedTypeId = st.galaxy.stars[starIdx].planets[defColony.orbit].typeId; + check("the planet entity's typeId matches the actual colonised planet", + planetEntity?.typeId === expectedTypeId, `${planetEntity?.typeId} vs ${expectedTypeId}`); + check('the planet sits at the fixed midpoint between world centre and its side\'s edge (worldWidth * 0.75)', + Math.abs(planetEntity.x - RULES.combatV2.worldWidth * 0.75) < 1e-9, `${planetEntity.x}`); + + // A colony with no Planetary Shield still fights, with no shield at all + // (not a stale nonzero default) — the ring the view draws is entirely + // conditional on this being > 0. + const unshieldedColony = st.colonies.find((c) => c.empireIdx === defenderIdx); + unshieldedColony.buildings = unshieldedColony.buildings.filter((b) => b !== 'planetaryshield'); + const preparedNoShield = Logic.prepareBattleAt(RULES, st, starIdx, attackerIdx, defenderIdx, {}); + const planetNoShield = preparedNoShield.battle.ships.find((s) => s.isPlanet); + check('a colony with no Planetary Shield building reaches battle with shield 0', + planetNoShield.shield === 0, `${planetNoShield.shield}`); + } + // Determinism, and auto-resolve agreeing with a played-out battle — same // structural guarantee as the live engine's equivalent check. {