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
This commit is contained in:
Brian Fertig 2026-07-16 23:22:29 -06:00
parent 0f2fc829c8
commit a91db38a0f
8 changed files with 322 additions and 57 deletions

View File

@ -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": [

View File

@ -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();
});
},
});
}
}
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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();
});
}
// ---------------------------------------------------------------------------

View File

@ -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 };
}

View File

@ -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.

View File

@ -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();