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:
parent
0f2fc829c8
commit
a91db38a0f
|
|
@ -250,11 +250,11 @@
|
||||||
],
|
],
|
||||||
|
|
||||||
"difficulties": [
|
"difficulties": [
|
||||||
{ "id": "chieftain", "name": "Chieftain", "humanResearchFactor": 0.8, "aiProdBonus": 0.8, "aiScienceBonus": 0.8, "aiStartUnits": 0, "aiAggression": 0.25 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 }
|
{ "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": [
|
"civTraits": [
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from '../../ui/Button.js';
|
||||||
import { TextInput } from '../../ui/TextInput.js';
|
import { TextInput } from '../../ui/TextInput.js';
|
||||||
import { MusicPlayer } from '../../ui/MusicPlayer.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 { compileRules, turnToYear, formatYear } from './CivilizationRules.js';
|
||||||
import * as Logic from './CivilizationLogic.js';
|
import * as Logic from './CivilizationLogic.js';
|
||||||
import { runAITurn, respondToProposal } from './CivilizationAI.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
|
const leaders = this.opponentsData.length ? this.opponentsData
|
||||||
: Array.from({ length: 8 }, (_, i) => ({ id: `leader${i}`, name: `Leader ${i + 1}`, spriteIndex: 0 }));
|
: Array.from({ length: 8 }, (_, i) => ({ id: `leader${i}`, name: `Leader ${i + 1}`, spriteIndex: 0 }));
|
||||||
this.setupLeaders = leaders;
|
this.setupLeaders = leaders;
|
||||||
this.pickedLeader = this.pickedLeader ?? 0;
|
this.pickedLeader = this.pickedLeader ?? Math.floor(Math.random() * leaders.length);
|
||||||
|
|
||||||
const perRow = 15;
|
const perRow = 15;
|
||||||
const cell = 96;
|
const cell = 96;
|
||||||
|
|
@ -172,12 +174,14 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
ring.setInteractive({ useHandCursor: true });
|
ring.setInteractive({ useHandCursor: true });
|
||||||
ring.on('pointerdown', () => {
|
ring.on('pointerdown', () => {
|
||||||
|
if (i === this.pickedLeader) return;
|
||||||
this.pickedLeader = i;
|
this.pickedLeader = i;
|
||||||
this.leaderMarks.forEach((m, j) => {
|
this.leaderMarks.forEach((m, j) => {
|
||||||
m.ring.setStrokeStyle(3, j === i ? COLORS.gold : COLORS.muted);
|
m.ring.setStrokeStyle(3, j === i ? COLORS.gold : COLORS.muted);
|
||||||
m.label.setColor(j === i ? COLORS.goldHex : COLORS.mutedHex);
|
m.label.setColor(j === i ? COLORS.goldHex : COLORS.mutedHex);
|
||||||
});
|
});
|
||||||
this.updateLeaderDetailPanel();
|
this.updateLeaderDetailPanel();
|
||||||
|
this.updateSetupPortrait({ playPick: true });
|
||||||
});
|
});
|
||||||
this.leaderMarks.push({ ring, label });
|
this.leaderMarks.push({ ring, label });
|
||||||
root.add([ring, face, label]);
|
root.add([ring, face, label]);
|
||||||
|
|
@ -258,6 +262,12 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
root.add([panelBg, nameText, traitText, descText]);
|
root.add([panelBg, nameText, traitText, descText]);
|
||||||
this.leaderDetailPanel = { nameText, traitText, descText };
|
this.leaderDetailPanel = { nameText, traitText, descText };
|
||||||
this.updateLeaderDetailPanel();
|
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
|
// 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(' • '));
|
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() {
|
hasSave() {
|
||||||
try { return !!localStorage.getItem(SAVE_KEY); } catch (_) { return false; }
|
try { return !!localStorage.getItem(SAVE_KEY); } catch (_) { return false; }
|
||||||
}
|
}
|
||||||
|
|
@ -334,6 +362,8 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
this.phase = 'playing';
|
this.phase = 'playing';
|
||||||
this.setupRoot?.destroy(true);
|
this.setupRoot?.destroy(true);
|
||||||
this.setupRoot = null;
|
this.setupRoot = null;
|
||||||
|
this.setupPortrait?.destroy();
|
||||||
|
this.setupPortrait = null;
|
||||||
this.view = new CivilizationMapView(this, this.rules, this.state, this.opponentsData, {
|
this.view = new CivilizationMapView(this, this.rules, this.state, this.opponentsData, {
|
||||||
onCityClick: (city) => this.onCityClick(city),
|
onCityClick: (city) => this.onCityClick(city),
|
||||||
onUnitClick: (unit) => this.onUnitClick(unit),
|
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
|
// `proceed` callback to call once it's done. Used for city-attack/capture
|
||||||
// announcements, which should show the attack playing out before the
|
// announcements, which should show the attack playing out before the
|
||||||
// outcome text, rather than the text popping up first.
|
// 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();
|
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)
|
// `proceed()` runs — a `pre` cinematic (camera pan + combat replay)
|
||||||
// should block input exactly like the popup that follows it does.
|
// should block input exactly like the popup that follows it does.
|
||||||
this.modalOpen = true;
|
this.modalOpen = true;
|
||||||
const { msg, onDismiss, pre } = item;
|
const {
|
||||||
const proceed = () => this.showStatusPopup(msg, onDismiss);
|
msg, onDismiss, pre, extra,
|
||||||
|
} = item;
|
||||||
|
const proceed = () => this.showStatusPopup(msg, onDismiss, extra);
|
||||||
if (pre) pre(proceed); else proceed();
|
if (pre) pre(proceed); else proceed();
|
||||||
}
|
}
|
||||||
|
|
||||||
showStatusPopup(msg, onDismiss) {
|
showStatusPopup(msg, onDismiss, extra) {
|
||||||
const cx = GAME_WIDTH / 2;
|
const cx = GAME_WIDTH / 2;
|
||||||
const cy = GAME_HEIGHT / 2;
|
const cy = GAME_HEIGHT / 2;
|
||||||
const root = this.add.container(0, 0).setDepth(D.modal);
|
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,
|
fontFamily: FONT, fontSize: '32px', color: COLORS.textHex,
|
||||||
wordWrap: { width: 680 }, align: 'center',
|
wordWrap: { width: 680 }, align: 'center',
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
const onContinue = () => {
|
const buttons = [];
|
||||||
btn.disableInteractive();
|
// 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
|
// 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
|
// 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
|
// 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),
|
onComplete: () => root.destroy(true),
|
||||||
});
|
});
|
||||||
this.modalOpen = false;
|
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]);
|
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() {
|
refreshHud() {
|
||||||
|
|
@ -1166,15 +1222,19 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
// "resolve now, animate after" trick as the player's own moves, just
|
// "resolve now, animate after" trick as the player's own moves, just
|
||||||
// via a before/after snapshot instead of a waypoint path.
|
// via a before/after snapshot instead of a waypoint path.
|
||||||
const combatEvents = this.collectNewCombatEvents();
|
const combatEvents = this.collectNewCombatEvents();
|
||||||
// Combats against one of the human's own cities are always deferred to
|
// Combats against any city the human has explored — their own or a
|
||||||
// a dedicated cinematic (camera pan + replay + outcome popup) played
|
// rival's — are always deferred to a dedicated cinematic (camera pan +
|
||||||
// at the start of the human's next turn, rather than silently replayed
|
// replay + outcome popup) played at the start of the human's next
|
||||||
// here (possibly off-screen) like every other AI-vs-AI combat.
|
// 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 human = this.state.humanIndex;
|
||||||
const elsewhere = [];
|
const elsewhere = [];
|
||||||
for (const e of combatEvents) {
|
for (const e of combatEvents) {
|
||||||
const city = Logic.cityAt(this.state, e.x, e.y);
|
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 });
|
this.pendingCityAttacks.push({ e, cityId: city.id, cityName: city.name, x: city.x, y: city.y });
|
||||||
} else {
|
} else {
|
||||||
elsewhere.push(e);
|
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
|
// Combats against any city the human has explored (their own or a
|
||||||
// runToHumanTurn()'s per-civ stepping (see stepCiv there) so they always
|
// rival's), deferred from runToHumanTurn()'s per-civ stepping (see
|
||||||
// get a camera pan + replay + outcome popup instead of possibly playing
|
// stepCiv there) so they always get a camera pan + replay + outcome popup
|
||||||
// silently off-screen alongside every other AI-vs-AI combat.
|
// instead of possibly playing silently off-screen.
|
||||||
announceCityAttacks() {
|
announceCityAttacks() {
|
||||||
const attacks = this.pendingCityAttacks;
|
const attacks = this.pendingCityAttacks;
|
||||||
this.pendingCityAttacks = [];
|
this.pendingCityAttacks = [];
|
||||||
|
|
@ -1277,6 +1337,27 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
const other = e.a === human ? e.b : e.a;
|
const other = e.a === human ? e.b : e.a;
|
||||||
this.announceStatus(`You have made contact with ${this.state.civs[other].name}`,
|
this.announceStatus(`You have made contact with ${this.state.civs[other].name}`,
|
||||||
() => this.openDiplomacy({ focusCivId: other, playIntro: true }));
|
() => 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();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -882,6 +882,19 @@ function resolveHut(rules, state, unit) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Combat
|
// 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) {
|
export function defenderStrength(rules, state, defUnit, attacker) {
|
||||||
const def = rules.units[defUnit.type];
|
const def = rules.units[defUnit.type];
|
||||||
const attDef = rules.units[attacker.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 (attDef.domain === 'air' && city.buildings.sambattery) d *= rules.buildings.sambattery.value;
|
||||||
}
|
}
|
||||||
if (def.flags.includes('antimounted') && attDef.flags.includes('mounted')) d *= 2;
|
if (def.flags.includes('antimounted') && attDef.flags.includes('mounted')) d *= 2;
|
||||||
|
d *= combatBonus(rules, state, defUnit.civ);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attackerStrength(rules, state, unit) {
|
export function attackerStrength(rules, state, unit) {
|
||||||
const def = rules.units[unit.type];
|
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) {
|
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);
|
for (const u of unitsAt(state, tx, ty).filter((un) => un.civ === defender.civ)) removeUnit(state, u);
|
||||||
}
|
}
|
||||||
attacker.hp = Math.max(1, duel.attackerHp);
|
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) {
|
if (!attDef.flags.includes('missile') && unitsAt(state, tx, ty).length === 0) {
|
||||||
attacker.x = tx;
|
attacker.x = tx;
|
||||||
attacker.y = ty;
|
attacker.y = ty;
|
||||||
|
|
@ -993,7 +1007,7 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
defender.hp = Math.max(1, duel.defenderHp);
|
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);
|
removeUnit(state, attacker);
|
||||||
}
|
}
|
||||||
spendMove(attacker, 3);
|
spendMove(attacker, 3);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
computeVisible, isUnitVisibleTo, shieldGrassAt,
|
computeVisible, isUnitVisibleTo, shieldGrassAt,
|
||||||
} from './CivilizationLogic.js';
|
} from './CivilizationLogic.js';
|
||||||
import { Tooltip } from '../../ui/Tooltip.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_W = 128;
|
||||||
export const TILE_H = 64;
|
export const TILE_H = 64;
|
||||||
|
|
@ -603,6 +603,11 @@ export class CivilizationMapView {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.cb.onCityClick?.(city);
|
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);
|
this.dynamic.add(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import * as Phaser from 'phaser';
|
||||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from '../../ui/Button.js';
|
||||||
import { createOpponentPortrait } from '../../ui/Portrait.js';
|
import { createOpponentPortrait } from '../../ui/Portrait.js';
|
||||||
|
import { Tooltip } from '../../ui/Tooltip.js';
|
||||||
import * as Logic from './CivilizationLogic.js';
|
import * as Logic from './CivilizationLogic.js';
|
||||||
|
import { describeTechTooltip } from './CivilizationTooltips.js';
|
||||||
|
|
||||||
const FONT = '"Julius Sans One"';
|
const FONT = '"Julius Sans One"';
|
||||||
const ERAS = ['ancient', 'medieval', 'industrial', 'modern'];
|
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);
|
maskShape.fillRect(left, top + 20, width - 60, height - 130);
|
||||||
scrollArea.setMask(maskShape.createGeometryMask());
|
scrollArea.setMask(maskShape.createGeometryMask());
|
||||||
|
|
||||||
|
const tooltip = new Tooltip(scene, { depth: 70 });
|
||||||
const available = new Set(Logic.availableTechs(rules, civ).map((t) => t.id));
|
const available = new Set(Logic.availableTechs(rules, civ).map((t) => t.id));
|
||||||
const colW = (width - 60) / 4;
|
const colW = (width - 60) / 4;
|
||||||
const rowH = 42;
|
const rowH = 42;
|
||||||
|
|
@ -83,20 +86,8 @@ export function openTechScreen(scene, rules, state, onClose) {
|
||||||
}).setOrigin(0, 0.5);
|
}).setOrigin(0, 0.5);
|
||||||
scrollArea.add(rect);
|
scrollArea.add(rect);
|
||||||
scrollArea.add(txt);
|
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.setInteractive({ useHandCursor: canPick });
|
||||||
rect.on('pointerover', () => {
|
tooltip.attachTo(rect, () => describeTechTooltip(rules, t, civ));
|
||||||
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(''));
|
|
||||||
if (canPick) {
|
if (canPick) {
|
||||||
rect.on('pointerdown', () => {
|
rect.on('pointerdown', () => {
|
||||||
Logic.setResearch(rules, state, civ, t.id);
|
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.
|
// Wheel scroll for tall columns.
|
||||||
const contentH = 70 + maxRows * rowH;
|
const contentH = 70 + maxRows * rowH;
|
||||||
const viewH = height - 130;
|
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));
|
scrollY = Phaser.Math.Clamp(scrollY + dy * 0.5, 0, Math.max(0, contentH - viewH));
|
||||||
scrollArea.y = -scrollY;
|
scrollArea.y = -scrollY;
|
||||||
}
|
}
|
||||||
root.once('destroy', () => scene.input.off('wheel', onWheel));
|
root.once('destroy', () => {
|
||||||
|
scene.input.off('wheel', onWheel);
|
||||||
|
tooltip.destroy();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,29 @@ const EFFECT_TEXT = {
|
||||||
defensesea: (b) => `Multiplies this city's defense against naval attacks by ${b.value}.`,
|
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) {
|
export function describeUnitTooltip(rules, unit) {
|
||||||
const lines = [
|
const lines = [
|
||||||
{ text: `Attack ${unit.attack} · Defense ${unit.defense} · Move ${unit.move}`, color: COLORS.goldHex },
|
{ 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 },
|
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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -251,10 +251,13 @@ export const TUNE = {
|
||||||
PULSAR_SPEED: 0.14,
|
PULSAR_SPEED: 0.14,
|
||||||
|
|
||||||
// Rim flippers hunt the player: rest between hops, then a hop that can be
|
// 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_DUR_MS: 260,
|
||||||
FLIP_REST_MS: 520, FLIP_REST_DECAY: 8, FLIP_REST_MIN_MS: 220,
|
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_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
|
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) {
|
export function flipRest(level) {
|
||||||
return Math.max(TUNE.FLIP_REST_MIN_MS, TUNE.FLIP_REST_MS - TUNE.FLIP_REST_DECAY * (level - 1));
|
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
|
// Level color bands — the arcade cycles its palette every 16 levels
|
||||||
|
|
@ -611,12 +620,14 @@ export class Sim {
|
||||||
updateFlipper(e, dt) {
|
updateFlipper(e, dt) {
|
||||||
if (e.state === 'climb') {
|
if (e.state === 'climb') {
|
||||||
e.t -= flipperSpeed(this.level) * (dt / 1000);
|
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;
|
e.climbFlipCd -= dt;
|
||||||
if (this.level >= TUNE.FLIP_CLIMB_LEVEL && e.climbFlipCd <= 0 && e.t > 0.15) {
|
if (e.climbFlipCd <= 0 && e.t > 0.15) {
|
||||||
e.climbFlipCd = TUNE.FLIP_CLIMB_COOLDOWN_MS * (0.7 + this.rng() * 0.6);
|
e.climbFlipCd = flipClimbCooldown(this.level) * (0.7 + this.rng() * 0.6);
|
||||||
if (this.rng() < TUNE.FLIP_CLIMB_CHANCE) {
|
const to = this.adjacentLaneToward(e.lane, this.playerLane());
|
||||||
e.lane = 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) {
|
if (e.t <= 0) {
|
||||||
|
|
@ -629,7 +640,7 @@ export class Sim {
|
||||||
if (e.restMs <= 0) {
|
if (e.restMs <= 0) {
|
||||||
const to = this.adjacentLaneToward(e.lane, this.playerLane());
|
const to = this.adjacentLaneToward(e.lane, this.playerLane());
|
||||||
if (to !== e.lane) {
|
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 {
|
} else {
|
||||||
e.restMs = flipRest(this.level);
|
e.restMs = flipRest(this.level);
|
||||||
}
|
}
|
||||||
|
|
@ -638,8 +649,8 @@ export class Sim {
|
||||||
e.flipMs += dt;
|
e.flipMs += dt;
|
||||||
if (e.flipMs >= TUNE.FLIP_DUR_MS) {
|
if (e.flipMs >= TUNE.FLIP_DUR_MS) {
|
||||||
e.lane = e.flipTo;
|
e.lane = e.flipTo;
|
||||||
e.state = 'rest';
|
e.state = e.flipReturn;
|
||||||
e.restMs = flipRest(this.level);
|
if (e.state === 'rest') e.restMs = flipRest(this.level);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A rim flipper sharing the player's lane is a grab — instant death.
|
// A rim flipper sharing the player's lane is a grab — instant death.
|
||||||
|
|
|
||||||
|
|
@ -705,6 +705,79 @@ if (RULES) {
|
||||||
check('duel ordering sane', strong > fp && fp > even);
|
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.
|
// Stack death outside cities/fortresses, survival inside.
|
||||||
{
|
{
|
||||||
const st = makeFlatState();
|
const st = makeFlatState();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue