From a91db38a0fae02e6a8b9840ce031404add39a4aa Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 16 Jul 2026 23:22:29 -0600 Subject: [PATCH] feat(civilization): balance difficulty combat, enhance UI tooltips, and improve setup flow - Introduce difficulty-based combat multipliers and veteran promotion chances (Chieftain/Warlord eased, Prince+ neutral) - Randomize leader selection on setup screen and add dynamic portrait with pick speech - Replace inline hover text with a centralized Tooltip component for tech tree, foreign cities, and buildings - Add descriptive flavor text for governments and terrain improvements - Add building completion notifications with a "VIEW CITY" action button - Expand deferred city attack cinematics to cover explored rival cities - Improve Tempest flipper AI to scale lane-flipping chance/cooldown with level and enable climbing flips - Add verification tests for difficulty combat modifiers and veteran rates --- data/civilization-rules.json | 10 +- src/games/civilization/CivilizationGame.js | 119 +++++++++++++++--- src/games/civilization/CivilizationLogic.js | 20 ++- src/games/civilization/CivilizationMapView.js | 7 +- src/games/civilization/CivilizationScreens.js | 27 ++-- .../civilization/CivilizationTooltips.js | 92 ++++++++++++++ src/games/tempest/TempestLogic.js | 31 +++-- tools/verifyCivilization.js | 73 +++++++++++ 8 files changed, 322 insertions(+), 57 deletions(-) diff --git a/data/civilization-rules.json b/data/civilization-rules.json index f11b618..1b9a5f6 100644 --- a/data/civilization-rules.json +++ b/data/civilization-rules.json @@ -250,11 +250,11 @@ ], "difficulties": [ - { "id": "chieftain", "name": "Chieftain", "humanResearchFactor": 0.8, "aiProdBonus": 0.8, "aiScienceBonus": 0.8, "aiStartUnits": 0, "aiAggression": 0.25 }, - { "id": "warlord", "name": "Warlord", "humanResearchFactor": 0.9, "aiProdBonus": 0.9, "aiScienceBonus": 0.9, "aiStartUnits": 0, "aiAggression": 0.4 }, - { "id": "prince", "name": "Prince", "humanResearchFactor": 1.0, "aiProdBonus": 1.0, "aiScienceBonus": 1.0, "aiStartUnits": 0, "aiAggression": 0.5 }, - { "id": "king", "name": "King", "humanResearchFactor": 1.1, "aiProdBonus": 1.25, "aiScienceBonus": 1.25, "aiStartUnits": 1, "aiAggression": 0.65 }, - { "id": "emperor", "name": "Emperor", "humanResearchFactor": 1.2, "aiProdBonus": 1.5, "aiScienceBonus": 1.5, "aiStartUnits": 1, "aiAggression": 0.8 } + { "id": "chieftain", "name": "Chieftain", "humanResearchFactor": 0.8, "aiProdBonus": 0.8, "aiScienceBonus": 0.8, "aiStartUnits": 0, "aiAggression": 0.25, "humanCombatBonus": 1.25, "aiCombatBonus": 0.8, "humanVetChance": 0.75 }, + { "id": "warlord", "name": "Warlord", "humanResearchFactor": 0.9, "aiProdBonus": 0.9, "aiScienceBonus": 0.9, "aiStartUnits": 0, "aiAggression": 0.4, "humanCombatBonus": 1.1, "aiCombatBonus": 0.9, "humanVetChance": 0.65 }, + { "id": "prince", "name": "Prince", "humanResearchFactor": 1.0, "aiProdBonus": 1.0, "aiScienceBonus": 1.0, "aiStartUnits": 0, "aiAggression": 0.5, "humanCombatBonus": 1.0, "aiCombatBonus": 1.0, "humanVetChance": 0.5 }, + { "id": "king", "name": "King", "humanResearchFactor": 1.1, "aiProdBonus": 1.25, "aiScienceBonus": 1.25, "aiStartUnits": 1, "aiAggression": 0.65, "humanCombatBonus": 1.0, "aiCombatBonus": 1.0, "humanVetChance": 0.5 }, + { "id": "emperor", "name": "Emperor", "humanResearchFactor": 1.2, "aiProdBonus": 1.5, "aiScienceBonus": 1.5, "aiStartUnits": 1, "aiAggression": 0.8, "humanCombatBonus": 1.0, "aiCombatBonus": 1.0, "humanVetChance": 0.5 } ], "civTraits": [ diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index 1308da8..3e3196e 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -10,6 +10,8 @@ import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { TextInput } from '../../ui/TextInput.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { createOpponentPortrait } from '../../ui/Portrait.js'; +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'; @@ -141,7 +143,7 @@ export default class CivilizationGame extends Phaser.Scene { const leaders = this.opponentsData.length ? this.opponentsData : Array.from({ length: 8 }, (_, i) => ({ id: `leader${i}`, name: `Leader ${i + 1}`, spriteIndex: 0 })); this.setupLeaders = leaders; - this.pickedLeader = this.pickedLeader ?? 0; + this.pickedLeader = this.pickedLeader ?? Math.floor(Math.random() * leaders.length); const perRow = 15; const cell = 96; @@ -172,12 +174,14 @@ export default class CivilizationGame extends Phaser.Scene { }).setOrigin(0.5); ring.setInteractive({ useHandCursor: true }); ring.on('pointerdown', () => { + if (i === this.pickedLeader) return; this.pickedLeader = i; this.leaderMarks.forEach((m, j) => { m.ring.setStrokeStyle(3, j === i ? COLORS.gold : COLORS.muted); m.label.setColor(j === i ? COLORS.goldHex : COLORS.mutedHex); }); this.updateLeaderDetailPanel(); + this.updateSetupPortrait({ playPick: true }); }); this.leaderMarks.push({ ring, label }); root.add([ring, face, label]); @@ -258,6 +262,12 @@ export default class CivilizationGame extends Phaser.Scene { root.add([panelBg, nameText, traitText, descText]); this.leaderDetailPanel = { nameText, traitText, descText }; this.updateLeaderDetailPanel(); + + // Video portrait of the currently-picked leader, in the empty margin + // left of the details panel — plays their idle loop; see + // updateSetupPortrait() for the pick-speech swap on selection change. + this.setupPortraitPos = { x: cx - panelW / 2 - 110, y: panelY + panelH / 2, radius: 85 }; + this.updateSetupPortrait({ playPick: false }); } // Reflects the currently-picked leader's starting-condition trait in the @@ -278,6 +288,24 @@ export default class CivilizationGame extends Phaser.Scene { descText.setText(lines.join(' • ')); } + // (Re)builds the setup screen's leader portrait for whoever is currently + // picked — createOpponentPortrait autoplays that leader's idle video + // immediately. When playPick is true (an actual selection change, not the + // initial random pick), also fires their one-line "pick" speech clip — + // deterministically, not through Portrait.js's playEmotion() 60%-chance + // gate (that gate exists to stop repeated in-match mood banter from + // getting spammy; a deliberate click should always be acknowledged). + updateSetupPortrait({ playPick }) { + this.setupPortrait?.destroy(); + const op = this.setupLeaders[this.pickedLeader]; + const { x, y, radius } = this.setupPortraitPos; + this.setupPortrait = createOpponentPortrait(this, op, x, y, radius, 11, { playIntro: false }); + if (playPick) { + const clip = op?.speech?.pick?.[0]; + if (clip) enqueueSpeech(clip); + } + } + hasSave() { try { return !!localStorage.getItem(SAVE_KEY); } catch (_) { return false; } } @@ -334,6 +362,8 @@ export default class CivilizationGame extends Phaser.Scene { this.phase = 'playing'; this.setupRoot?.destroy(true); this.setupRoot = null; + this.setupPortrait?.destroy(); + this.setupPortrait = null; this.view = new CivilizationMapView(this, this.rules, this.state, this.opponentsData, { onCityClick: (city) => this.onCityClick(city), onUnitClick: (unit) => this.onUnitClick(unit), @@ -466,8 +496,16 @@ export default class CivilizationGame extends Phaser.Scene { // `proceed` callback to call once it's done. Used for city-attack/capture // announcements, which should show the attack playing out before the // outcome text, rather than the text popping up first. - announceStatus(msg, onDismiss, pre) { - this.statusQueue.push({ msg, onDismiss, pre }); + // + // `extra`, if given, is a second button `{ label, onClick }` shown next to + // CONTINUE — dismisses the popup the same way, then calls `onClick` + // instead of advancing the queue (e.g. building-complete opens the city + // screen; that screen's own onClose is responsible for resuming the queue + // once it closes — see the buildingDone handler in announceEvents()). + announceStatus(msg, onDismiss, pre, extra) { + this.statusQueue.push({ + msg, onDismiss, pre, extra, + }); if (!this.modalOpen) this.showNextStatus(); } @@ -478,12 +516,14 @@ export default class CivilizationGame extends Phaser.Scene { // `proceed()` runs — a `pre` cinematic (camera pan + combat replay) // should block input exactly like the popup that follows it does. this.modalOpen = true; - const { msg, onDismiss, pre } = item; - const proceed = () => this.showStatusPopup(msg, onDismiss); + const { + msg, onDismiss, pre, extra, + } = item; + const proceed = () => this.showStatusPopup(msg, onDismiss, extra); if (pre) pre(proceed); else proceed(); } - showStatusPopup(msg, onDismiss) { + showStatusPopup(msg, onDismiss, extra) { const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; const root = this.add.container(0, 0).setDepth(D.modal); @@ -493,8 +533,13 @@ export default class CivilizationGame extends Phaser.Scene { fontFamily: FONT, fontSize: '32px', color: COLORS.textHex, wordWrap: { width: 680 }, align: 'center', }).setOrigin(0.5); - const onContinue = () => { - btn.disableInteractive(); + const buttons = []; + // Shared dismiss animation for whichever button is clicked — shrinks the + // message into the status log and fades the popup out — differing only + // in what runs afterward (`then`): resume the queue, or (for the extra + // button) do something else that's responsible for resuming it itself. + const dismiss = (then) => { + buttons.forEach((b) => b.disableInteractive()); // Detach without destroying — root sits at (0,0), so txt's numeric x/y // already equal its on-screen position; Phaser re-adds a removed child // straight to the scene's top-level display list, so it keeps rendering @@ -520,10 +565,21 @@ export default class CivilizationGame extends Phaser.Scene { onComplete: () => root.destroy(true), }); this.modalOpen = false; - if (onDismiss) onDismiss(); else this.showNextStatus(); + then(); }; - const btn = new Button(this, cx, cy + 100, 'CONTINUE', onContinue, { width: 220, height: 56 }); + const btnX = extra ? cx - 120 : cx; + const btn = new Button(this, btnX, cy + 100, 'CONTINUE', + () => dismiss(() => { if (onDismiss) onDismiss(); else this.showNextStatus(); }), + { width: 220, height: 56 }); + buttons.push(btn); root.add([dim, panel, txt, btn]); + if (extra) { + const extraBtn = new Button(this, cx + 120, cy + 100, extra.label, + () => dismiss(() => extra.onClick()), + { width: 220, height: 56, variant: 'ghost' }); + buttons.push(extraBtn); + root.add(extraBtn); + } } refreshHud() { @@ -1166,15 +1222,19 @@ export default class CivilizationGame extends Phaser.Scene { // "resolve now, animate after" trick as the player's own moves, just // via a before/after snapshot instead of a waypoint path. const combatEvents = this.collectNewCombatEvents(); - // Combats against one of the human's own cities are always deferred to - // a dedicated cinematic (camera pan + replay + outcome popup) played - // at the start of the human's next turn, rather than silently replayed - // here (possibly off-screen) like every other AI-vs-AI combat. + // Combats against any city the human has explored — their own or a + // rival's — are always deferred to a dedicated cinematic (camera pan + + // replay + outcome popup) played at the start of the human's next + // turn, rather than silently replayed here (possibly off-screen) like + // ordinary field battles. City tiles stay marked once explored (see + // MapView.refresh()'s `explored[idx]` check), so this reaches a known + // rival city even if it's currently outside the fog-of-war vision set. const human = this.state.humanIndex; const elsewhere = []; for (const e of combatEvents) { const city = Logic.cityAt(this.state, e.x, e.y); - if (city && city.civ === human) { + const cityExplored = city && this.state.explored[human][Logic.tileIndex(this.state.world, city.x, city.y)]; + if (city && (city.civ === human || cityExplored)) { this.pendingCityAttacks.push({ e, cityId: city.id, cityName: city.name, x: city.x, y: city.y }); } else { elsewhere.push(e); @@ -1221,10 +1281,10 @@ export default class CivilizationGame extends Phaser.Scene { } } - // Combats against one of the human's own cities, deferred from - // runToHumanTurn()'s per-civ stepping (see stepCiv there) so they always - // get a camera pan + replay + outcome popup instead of possibly playing - // silently off-screen alongside every other AI-vs-AI combat. + // Combats against any city the human has explored (their own or a + // rival's), deferred from runToHumanTurn()'s per-civ stepping (see + // stepCiv there) so they always get a camera pan + replay + outcome popup + // instead of possibly playing silently off-screen. announceCityAttacks() { const attacks = this.pendingCityAttacks; this.pendingCityAttacks = []; @@ -1277,6 +1337,27 @@ export default class CivilizationGame extends Phaser.Scene { const other = e.a === human ? e.b : e.a; this.announceStatus(`You have made contact with ${this.state.civs[other].name}`, () => this.openDiplomacy({ focusCivId: other, playIntro: true })); + } else if (e.type === 'buildingDone' && e.civ === human) { + const city = Logic.cityById(this.state, e.cityId); + const building = this.rules.buildings[e.building]; + if (!city || !building) continue; + // completeBuild() already auto-picked a fallback next build (see + // CivilizationLogic.js pickNextBuild) so the city doesn't idle — + // VIEW CITY is here so the player can override that choice. + this.announceStatus(`${building.name} completed in ${city.name}!`, undefined, + (proceed) => { this.view.panToTile(city.x, city.y); this.time.delayedCall(500, proceed); }, + { + label: 'VIEW CITY', + onClick: () => { + this.modalOpen = true; + openCityScreen(this, this.rules, this.state, city, () => { + this.modalOpen = false; + this.view.refresh(); + this.refreshHud(); + this.showNextStatus(); + }); + }, + }); } } } diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index fc8a2b6..e9d48d3 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -882,6 +882,19 @@ function resolveHut(rules, state, unit) { // --------------------------------------------------------------------------- // Combat +// Easier-difficulty combat handicap: boosts the human side and softens the +// AI side on Chieftain/Warlord; neutral (1.0/1.0/0.5) from Prince up, so +// Prince stays the unmodified reference difficulty. +function combatBonus(rules, state, civIdx) { + const diff = rules.difficulties[state.difficultyId]; + return state.civs[civIdx].human ? diff.humanCombatBonus : diff.aiCombatBonus; +} + +function vetChance(rules, state, civIdx) { + const diff = rules.difficulties[state.difficultyId]; + return state.civs[civIdx].human ? diff.humanVetChance : 0.5; +} + export function defenderStrength(rules, state, defUnit, attacker) { const def = rules.units[defUnit.type]; const attDef = rules.units[attacker.type]; @@ -902,12 +915,13 @@ export function defenderStrength(rules, state, defUnit, attacker) { if (attDef.domain === 'air' && city.buildings.sambattery) d *= rules.buildings.sambattery.value; } if (def.flags.includes('antimounted') && attDef.flags.includes('mounted')) d *= 2; + d *= combatBonus(rules, state, defUnit.civ); return d; } export function attackerStrength(rules, state, unit) { const def = rules.units[unit.type]; - return def.attack * (unit.vet ? VET_BONUS : 1) * (unit.hp / def.hp); + return def.attack * (unit.vet ? VET_BONUS : 1) * (unit.hp / def.hp) * combatBonus(rules, state, unit.civ); } export function pickDefender(rules, state, x, y, attacker) { @@ -982,7 +996,7 @@ export function resolveAttack(rules, state, attacker, tx, ty) { for (const u of unitsAt(state, tx, ty).filter((un) => un.civ === defender.civ)) removeUnit(state, u); } attacker.hp = Math.max(1, duel.attackerHp); - if (!attacker.vet && rand(state) < 0.5) attacker.vet = true; + if (!attacker.vet && rand(state) < vetChance(rules, state, attacker.civ)) attacker.vet = true; if (!attDef.flags.includes('missile') && unitsAt(state, tx, ty).length === 0) { attacker.x = tx; attacker.y = ty; @@ -993,7 +1007,7 @@ export function resolveAttack(rules, state, attacker, tx, ty) { } } else { defender.hp = Math.max(1, duel.defenderHp); - if (!defender.vet && rand(state) < 0.5) defender.vet = true; + if (!defender.vet && rand(state) < vetChance(rules, state, defender.civ)) defender.vet = true; removeUnit(state, attacker); } spendMove(attacker, 3); diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index 1bea2d0..af7b1ed 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -17,7 +17,7 @@ import { computeVisible, isUnitVisibleTo, shieldGrassAt, } from './CivilizationLogic.js'; import { Tooltip } from '../../ui/Tooltip.js'; -import { describeUnitStackTooltip } from './CivilizationTooltips.js'; +import { describeUnitStackTooltip, describeCityTooltip } from './CivilizationTooltips.js'; export const TILE_W = 128; export const TILE_H = 64; @@ -603,6 +603,11 @@ export class CivilizationMapView { event.stopPropagation(); this.cb.onCityClick?.(city); }); + // Hover info for other leaders' cities only — the human already gets + // full detail on their own cities via onCityClick's city screen. + if (city.civ !== this.humanIdx) { + this.tooltip.attachTo(banner, () => describeCityTooltip(this.rules, civ, this.opponentsData, city)); + } this.dynamic.add(container); } diff --git a/src/games/civilization/CivilizationScreens.js b/src/games/civilization/CivilizationScreens.js index 967586d..8bc643c 100644 --- a/src/games/civilization/CivilizationScreens.js +++ b/src/games/civilization/CivilizationScreens.js @@ -5,7 +5,9 @@ import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { createOpponentPortrait } from '../../ui/Portrait.js'; +import { Tooltip } from '../../ui/Tooltip.js'; import * as Logic from './CivilizationLogic.js'; +import { describeTechTooltip } from './CivilizationTooltips.js'; const FONT = '"Julius Sans One"'; const ERAS = ['ancient', 'medieval', 'industrial', 'modern']; @@ -55,6 +57,7 @@ export function openTechScreen(scene, rules, state, onClose) { maskShape.fillRect(left, top + 20, width - 60, height - 130); scrollArea.setMask(maskShape.createGeometryMask()); + const tooltip = new Tooltip(scene, { depth: 70 }); const available = new Set(Logic.availableTechs(rules, civ).map((t) => t.id)); const colW = (width - 60) / 4; const rowH = 42; @@ -83,20 +86,8 @@ export function openTechScreen(scene, rules, state, onClose) { }).setOrigin(0, 0.5); scrollArea.add(rect); scrollArea.add(txt); - // Gate summary on hover. - const g = rules.techGates[t.id]; - const unlocks = [ - ...g.units.map((u) => rules.units[u].name), - ...g.buildings.map((b) => rules.buildings[b].name), - ...g.governments.map((gv) => rules.governments[gv].name), - ...g.improvements.map((im) => rules.improvements[im].name), - ]; rect.setInteractive({ useHandCursor: canPick }); - rect.on('pointerover', () => { - hoverText.setText(`${t.name}${t.prereqs.length ? ` ⟵ ${t.prereqs.map((p) => rules.techs[p].name).join(' + ')}` : ''}` - + (unlocks.length ? `\nUnlocks: ${unlocks.join(', ')}` : '')); - }); - rect.on('pointerout', () => hoverText.setText('')); + tooltip.attachTo(rect, () => describeTechTooltip(rules, t, civ)); if (canPick) { rect.on('pointerdown', () => { Logic.setResearch(rules, state, civ, t.id); @@ -106,11 +97,6 @@ export function openTechScreen(scene, rules, state, onClose) { }); }); - const hoverText = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + height / 2 - 46, '', { - fontFamily: FONT, fontSize: '17px', color: COLORS.goldHex, align: 'center', - }).setOrigin(0.5); - root.add(hoverText); - // Wheel scroll for tall columns. const contentH = 70 + maxRows * rowH; const viewH = height - 130; @@ -120,7 +106,10 @@ export function openTechScreen(scene, rules, state, onClose) { scrollY = Phaser.Math.Clamp(scrollY + dy * 0.5, 0, Math.max(0, contentH - viewH)); scrollArea.y = -scrollY; } - root.once('destroy', () => scene.input.off('wheel', onWheel)); + root.once('destroy', () => { + scene.input.off('wheel', onWheel); + tooltip.destroy(); + }); } // --------------------------------------------------------------------------- diff --git a/src/games/civilization/CivilizationTooltips.js b/src/games/civilization/CivilizationTooltips.js index 8b6a097..57ecf2b 100644 --- a/src/games/civilization/CivilizationTooltips.js +++ b/src/games/civilization/CivilizationTooltips.js @@ -55,6 +55,29 @@ const EFFECT_TEXT = { defensesea: (b) => `Multiplies this city's defense against naval attacks by ${b.value}.`, }; +// Short blurbs for the handful of tech-unlocked governments — again hand +// authored since the rules JSON carries only raw numeric fields, keyed off +// what CivilizationLogic.js actually does with those fields (tradeBonus, +// corruptionFactor, despotPenalty, freeUnits, unitUpkeep, warPenalty). +const GOVERNMENT_TEXT = { + monarchy: 'Cuts corruption well below Despotism and drops the Despotism penalty to tile yields.', + communism: 'Low, distance-independent corruption anywhere in the empire, plus 6 free unit upkeep per city.', + republic: 'Sharply reduces corruption and adds +1 trade to tiles already producing trade, at the cost of unit upkeep.', + democracy: 'Eliminates corruption entirely and adds +1 trade to productive tiles; units cost gold upkeep and broken treaties cost double reputation.', +}; + +// Short blurbs for tech-unlocked terrain improvements (worker orders), keyed +// off what CivilizationLogic.js actually does with each one. +const IMPROVEMENT_TEXT = { + road: 'Cuts movement between roaded tiles to a flat 1 point and adds +1 trade to tiles already producing trade.', + railroad: 'Movement between railroaded tiles is free, and adds +1 shield to tiles already producing shields.', + irrigation: 'Adds food to the tile, boosting the city that works it.', + farmland: 'With a Supermarket in the city, boosts food from irrigated tiles by 50%.', + mine: 'Adds shields to the tile, boosting the city that works it.', + fortress: 'Doubles a unit\'s defense on the tile and lets even a single unit protect it like a city would.', + transform: 'Permanently changes the tile\'s terrain type (Engineers only).', +}; + export function describeUnitTooltip(rules, unit) { const lines = [ { text: `Attack ${unit.attack} · Defense ${unit.defense} · Move ${unit.move}`, color: COLORS.goldHex }, @@ -113,3 +136,72 @@ export function describeUnitStackTooltip(rules, civ, opponentsData, units) { icon: { texture: 'opponents', frame: opData?.spriteIndex ?? 0, color: civ.color, label: civ.name }, }; } + +// Hover tooltip for a foreign city: which leader rules it plus the basic +// facts visible from outside (population, capital/walls status) — no build +// queue or yields, since this is a rival's city and this build has no +// espionage/embassy system to justify seeing that. +export function describeCityTooltip(rules, civ, opponentsData, city) { + const opData = opponentsData?.find((o) => o.id === civ.leaderId); + const lines = [ + { text: civ.name, color: civ.color }, + { text: `Population ${city.size}`, color: COLORS.goldHex }, + ]; + if (city.buildings.palace) lines.push({ text: '• Capital' }); + if (city.buildings.citywalls) lines.push({ text: '• Defended by City Walls' }); + return { + title: city.name, + lines, + icon: { texture: 'opponents', frame: opData?.spriteIndex ?? 0, color: civ.color, label: civ.name }, + }; +} + +// Hover tooltip for a tech-tree entry: prerequisites (only worth showing +// once — omitted for techs the civ already knows) followed by everything +// the tech unlocks, grouped by kind. `civ` is used only to decide whether +// the tech is already known/repeated, not to filter what's shown. +export function describeTechTooltip(rules, tech, civ) { + const known = !!civ.known[tech.id] || (tech.repeatable && civ.futureCount > 0); + const g = rules.techGates[tech.id]; + const lines = []; + + if (!known && tech.prereqs.length) { + lines.push({ + text: `Requires: ${tech.prereqs.map((p) => rules.techs[p].name).join(' + ')}`, + color: COLORS.mutedHex, + }); + } + + const section = (label, items) => { + if (!items.length) return; + lines.push({ text: label, color: COLORS.goldHex }); + for (const item of items) lines.push({ text: `• ${item}` }); + }; + + section('Units:', g.units.map((id) => { + const u = rules.units[id]; + return `${u.name} (A${u.attack} D${u.defense} M${u.move})`; + })); + section('Buildings:', g.buildings.map((id) => { + const b = rules.buildings[id]; + const describe = EFFECT_TEXT[b.effect]; + return describe ? `${b.name} — ${describe(b)}` : b.name; + })); + section('Government:', g.governments.map((id) => { + const gv = rules.governments[id]; + const text = GOVERNMENT_TEXT[id]; + return text ? `${gv.name} — ${text}` : gv.name; + })); + section('Terrain improvements:', g.improvements.map((id) => { + const im = rules.improvements[id]; + const text = IMPROVEMENT_TEXT[id]; + return text ? `${im.name} — ${text}` : im.name; + })); + section('Leads to further research:', g.prereqOf.map((id) => rules.techs[id].name)); + + if (tech.repeatable) { + lines.push({ text: 'Repeatable — each additional discovery adds to your score.', color: COLORS.mutedHex }); + } + + return { title: tech.name, lines }; +} diff --git a/src/games/tempest/TempestLogic.js b/src/games/tempest/TempestLogic.js index 5525120..acdb039 100644 --- a/src/games/tempest/TempestLogic.js +++ b/src/games/tempest/TempestLogic.js @@ -251,10 +251,13 @@ export const TUNE = { PULSAR_SPEED: 0.14, // Rim flippers hunt the player: rest between hops, then a hop that can be - // shot mid-flight. Ascending flippers also hop lanes from FLIP_CLIMB_LEVEL. + // shot mid-flight. Flippers also flip lane-to-lane while still climbing + // (their arcade signature) — chance ramps up and cooldown shortens with + // level so early levels stay gentle and later ones get aggressive. FLIP_DUR_MS: 260, FLIP_REST_MS: 520, FLIP_REST_DECAY: 8, FLIP_REST_MIN_MS: 220, - FLIP_CLIMB_LEVEL: 5, FLIP_CLIMB_CHANCE: 0.25, FLIP_CLIMB_COOLDOWN_MS: 1400, + FLIP_CLIMB_CHANCE_BASE: 0.3, FLIP_CLIMB_CHANCE_GROWTH: 0.01, FLIP_CLIMB_CHANCE_MAX: 0.55, + FLIP_CLIMB_COOLDOWN_MS: 1100, FLIP_CLIMB_COOLDOWN_DECAY: 20, FLIP_CLIMB_COOLDOWN_MIN_MS: 600, SPIKE_MIN_T: 0.22, // how close to the rim a spike can grow SPIKE_TRIM: 0.06, // how much one shot shaves off a spike tip @@ -317,6 +320,12 @@ export function spawnInterval(level) { export function flipRest(level) { return Math.max(TUNE.FLIP_REST_MIN_MS, TUNE.FLIP_REST_MS - TUNE.FLIP_REST_DECAY * (level - 1)); } +export function flipClimbChance(level) { + return Math.min(TUNE.FLIP_CLIMB_CHANCE_MAX, TUNE.FLIP_CLIMB_CHANCE_BASE + TUNE.FLIP_CLIMB_CHANCE_GROWTH * (level - 1)); +} +export function flipClimbCooldown(level) { + return Math.max(TUNE.FLIP_CLIMB_COOLDOWN_MIN_MS, TUNE.FLIP_CLIMB_COOLDOWN_MS - TUNE.FLIP_CLIMB_COOLDOWN_DECAY * (level - 1)); +} // --------------------------------------------------------------------------- // Level color bands — the arcade cycles its palette every 16 levels @@ -611,12 +620,14 @@ export class Sim { updateFlipper(e, dt) { if (e.state === 'climb') { e.t -= flipperSpeed(this.level) * (dt / 1000); - // High-level flippers hop lanes on the way up, too. + // Flippers hop lanes on the way up, not just at the rim — animated + // the same way as a rim flip, then climbing resumes in the new lane. e.climbFlipCd -= dt; - if (this.level >= TUNE.FLIP_CLIMB_LEVEL && e.climbFlipCd <= 0 && e.t > 0.15) { - e.climbFlipCd = TUNE.FLIP_CLIMB_COOLDOWN_MS * (0.7 + this.rng() * 0.6); - if (this.rng() < TUNE.FLIP_CLIMB_CHANCE) { - e.lane = this.adjacentLaneToward(e.lane, this.playerLane()); + if (e.climbFlipCd <= 0 && e.t > 0.15) { + e.climbFlipCd = flipClimbCooldown(this.level) * (0.7 + this.rng() * 0.6); + const to = this.adjacentLaneToward(e.lane, this.playerLane()); + if (to !== e.lane && this.rng() < flipClimbChance(this.level)) { + e.state = 'flip'; e.flipFrom = e.lane; e.flipTo = to; e.flipMs = 0; e.flipReturn = 'climb'; } } if (e.t <= 0) { @@ -629,7 +640,7 @@ export class Sim { if (e.restMs <= 0) { const to = this.adjacentLaneToward(e.lane, this.playerLane()); if (to !== e.lane) { - e.state = 'flip'; e.flipFrom = e.lane; e.flipTo = to; e.flipMs = 0; + e.state = 'flip'; e.flipFrom = e.lane; e.flipTo = to; e.flipMs = 0; e.flipReturn = 'rest'; } else { e.restMs = flipRest(this.level); } @@ -638,8 +649,8 @@ export class Sim { e.flipMs += dt; if (e.flipMs >= TUNE.FLIP_DUR_MS) { e.lane = e.flipTo; - e.state = 'rest'; - e.restMs = flipRest(this.level); + e.state = e.flipReturn; + if (e.state === 'rest') e.restMs = flipRest(this.level); } } // A rim flipper sharing the player's lane is a grab — instant death. diff --git a/tools/verifyCivilization.js b/tools/verifyCivilization.js index 276ddc2..1c0aa2e 100644 --- a/tools/verifyCivilization.js +++ b/tools/verifyCivilization.js @@ -705,6 +705,79 @@ if (RULES) { check('duel ordering sane', strong > fp && fp > even); } + // Difficulty combat handicap: Chieftain/Warlord ease combat for the human + // side and soften the AI side; Prince/King/Emperor stay neutral (1.0/1.0). + { + const stPrince = makeFlatState(); + const humanP = Logic.spawnUnit(RULES, stPrince, 0, 'legion', 5, 5, null); + const aiP = Logic.spawnUnit(RULES, stPrince, 1, 'legion', 5, 5, null); + const humanAtkPrince = Logic.attackerStrength(RULES, stPrince, humanP); + const aiAtkPrince = Logic.attackerStrength(RULES, stPrince, aiP); + const humanDefPrince = Logic.defenderStrength(RULES, stPrince, humanP, aiP); + const aiDefPrince = Logic.defenderStrength(RULES, stPrince, aiP, humanP); + + for (const [diffId, expectHuman, expectAi] of [ + ['chieftain', 1.25, 0.8], + ['warlord', 1.1, 0.9], + ]) { + const st = makeFlatState(); + st.difficultyId = diffId; + const human = Logic.spawnUnit(RULES, st, 0, 'legion', 5, 5, null); + const ai = Logic.spawnUnit(RULES, st, 1, 'legion', 5, 5, null); + const humanAtk = Logic.attackerStrength(RULES, st, human); + const aiAtk = Logic.attackerStrength(RULES, st, ai); + const humanDef = Logic.defenderStrength(RULES, st, human, ai); + const aiDef = Logic.defenderStrength(RULES, st, ai, human); + check(`${diffId} human attack x${expectHuman}`, + Math.abs(humanAtk - humanAtkPrince * expectHuman) < 1e-9, `${humanAtk}`); + check(`${diffId} ai attack x${expectAi}`, + Math.abs(aiAtk - aiAtkPrince * expectAi) < 1e-9, `${aiAtk}`); + check(`${diffId} human defense x${expectHuman}`, + Math.abs(humanDef - humanDefPrince * expectHuman) < 1e-9, `${humanDef}`); + check(`${diffId} ai defense x${expectAi}`, + Math.abs(aiDef - aiDefPrince * expectAi) < 1e-9, `${aiDef}`); + } + + for (const diffId of ['king', 'emperor']) { + const st = makeFlatState(); + st.difficultyId = diffId; + const human = Logic.spawnUnit(RULES, st, 0, 'legion', 5, 5, null); + const ai = Logic.spawnUnit(RULES, st, 1, 'legion', 5, 5, null); + check(`${diffId} human attack unchanged from prince`, + Math.abs(Logic.attackerStrength(RULES, st, human) - humanAtkPrince) < 1e-9); + check(`${diffId} ai attack unchanged from prince`, + Math.abs(Logic.attackerStrength(RULES, st, ai) - aiAtkPrince) < 1e-9); + } + } + + // Veteran-promotion odds: elevated for the human side on Chieftain, flat + // 50% for AI regardless of difficulty, flat 50% for everyone at Prince+. + { + const trials = QUICK ? 1500 : 6000; + function vetRate(difficultyId, civIdx) { + let promos = 0; + for (let i = 0; i < trials; i += 1) { + const st = makeFlatState(); + st.difficultyId = difficultyId; + st.rngState = (i * 2654435761) | 0; + const attacker = Logic.spawnUnit(RULES, st, civIdx, 'armor', 4, 5, null); + const defender = Logic.spawnUnit(RULES, st, 1 - civIdx, 'warriors', 5, 5, null); + Logic.resolveAttack(RULES, st, attacker, 5, 5); + if (attacker.vet) promos += 1; + } + return promos / trials; + } + const chieftainHuman = vetRate('chieftain', 0); + const princeHuman = vetRate('prince', 0); + const chieftainAi = vetRate('chieftain', 1); + check('chieftain human vet rate ~0.75', chieftainHuman > 0.7 && chieftainHuman < 0.8, + `${chieftainHuman.toFixed(3)}`); + check('prince human vet rate ~0.5', princeHuman > 0.46 && princeHuman < 0.54, + `${princeHuman.toFixed(3)}`); + check('chieftain ai vet rate unchanged ~0.5', chieftainAi > 0.46 && chieftainAi < 0.54, + `${chieftainAi.toFixed(3)}`); + } + // Stack death outside cities/fortresses, survival inside. { const st = makeFlatState();