Added warfare depth, focused research and a reworked HUD

- Tile improvements (fortifications, radar towers, coastal cannons), trenches,
  zone of control, support-unit capture and launch transports, with parabolic
  barrage arcs drawn between attacker and target.
- Focused research that pours points straight into the chosen technology, with
  per-technology progress kept when the focus changes, plus repeatable levels.
- Popularity reworked to integer points, war and pillage effects, minority
  policies and month-over-month arrows on the nation summary.
- City progress bars, square improvement buttons, a technology tab of wide
  short cards with progress bars, transport cargo readout and double-click to
  open a city panel.
This commit is contained in:
2026-09-21 01:07:36 +02:00
parent 914bfee6ab
commit ede258d39f
62 changed files with 3842 additions and 463 deletions
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">coastal cannon</text>
</svg>

After

Width:  |  Height:  |  Size: 484 B

+1
View File
@@ -0,0 +1 @@
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M256 21c-66.72 0-121 54.28-121 121s54.28 121 121 121 121-54.28 121-121S322.72 21 256 21zm0 18c56.992 0 103 46.008 103 103s-46.008 103-103 103-103-46.008-103-103S199.008 39 256 39zm0 11.75-69.4 52.05 10.8 14.4L256 73.25l58.6 43.95 10.8-14.4L256 50.75zm0 48-69.4 52.05 10.8 14.4 58.6-43.95 58.6 43.95 10.8-14.4L256 98.75zm0 48-69.4 52.05 10.8 14.4 58.6-43.95 58.6 43.95 10.8-14.4-69.4-52.05zM53.562 185l-7 14h66.876l-7-14H53.562zm352 0-7 14h66.875l-7-14h-52.875zM41 217v46h78v-46H41zm352 0v46h78v-46h-78zM64 231h32v18H64v-18zm352 0h32v18h-32v-18zM38.486 281l-10 30h455.028l-10-30H38.486zM25 329v158h199v-87h64v87h199V329H25zm55 14h32v18H80v-18zm80 0h32v18h-32v-18zm80 0h32v18h-32v-18zm80 0h32v18h-32v-18zm80 0h32v18h-32v-18z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>

After

Width:  |  Height:  |  Size: 982 B

+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="15px" height="15px" viewBox="0 0 15 15" style="enable-background:new 0 0 15 15;" xml:space="preserve">
<path d="M5.33,11c0.6745,0.009,1.3262,0.245,1.85,0.67l0.26,0.23h0.05l0.31-0.28c0.9817-0.8115,2.379-0.8846,3.44-0.18L13.5,7L12,6
V2.45c-0.0466-0.8-0.6989-1.4305-1.5-1.45H10c0.0343-0.518-0.3579-0.9657-0.8759-1C9.0828-0.0027,9.0413-0.0027,9,0H6
C5.482-0.0343,5.0343,0.3579,5,0.8759C4.9973,0.9172,4.9973,0.9587,5,1H4.5C3.6989,1.0195,3.0466,1.65,3,2.45V6L1.5,7l2.25,4.53
C4.2143,11.2041,4.7631,11.02,5.33,11z M4,2.45c0.0105-0.2594,0.2293-0.4611,0.4886-0.4506C4.4924,1.9996,4.4962,1.9998,4.5,2h6
c0.259-0.0171,0.4829,0.1789,0.5,0.4379c0.0014,0.0207,0.0014,0.0414,0,0.0621v3l-3.5-2L4,5.45C4,5.45,4,2.45,4,2.45z M14,13v1
c-0.2626,0.0194-0.5069,0.1416-0.68,0.34c-0.2717,0.2883-0.6178,0.4959-1,0.6c-0.6147,0.1815-1.2795,0.0425-1.77-0.37l-0.39-0.35
c-0.3043-0.3026-0.7957-0.3026-1.1,0c-0.14,0.12-0.27,0.25-0.42,0.37c-0.7328,0.5856-1.7814,0.556-2.48-0.07l-0.39-0.35
c-0.2864-0.2502-0.7136-0.2502-1,0c-0.19,0.15-0.36,0.32-0.55,0.47c-0.7043,0.5215-1.6732,0.4968-2.35-0.06l-0.31-0.27
C1.4153,14.1443,1.2172,14.0346,1,14v-1c0.2585-0.032,0.5205,0.0169,0.75,0.14c0.2782,0.1722,0.5424,0.3661,0.79,0.58
c0.2051,0.2235,0.5147,0.319,0.81,0.25c0.1125-0.0333,0.2177-0.0876,0.31-0.16c0.16-0.12,0.29-0.26,0.45-0.39
c0.7106-0.5888,1.7394-0.5888,2.45,0c0.16,0.13,0.31,0.28,0.47,0.41c0.2864,0.2502,0.7136,0.2502,1,0c0.16-0.13,0.31-0.28,0.47-0.41
c0.7123-0.5943,1.7477-0.5943,2.46,0c0.15,0.13,0.29,0.27,0.44,0.39c0.2864,0.2502,0.7136,0.2502,1,0l0.47-0.41
C13.1895,13.1408,13.5886,12.9995,14,13z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">mobile radar</text>
</svg>

After

Width:  |  Height:  |  Size: 482 B

+1
View File
@@ -0,0 +1 @@
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M227.47 31.47c-67.048 70.547-83.972 178.936-32.907 267.467 51.07 88.544 153.333 128.083 247.906 105.22l-215-372.688zm232.81 82.155a31.106 31.106 0 0 0-14.81 4.188c-11.303 6.53-17.02 19.035-15.44 31.218l-96.31 29.25 34.842 60.345L442 169.812c9.755 7.504 23.456 8.826 34.78 2.282 14.992-8.663 20.117-27.822 11.47-42.813-5.945-10.305-16.856-15.948-27.97-15.655zm-311.874 107.5-26.656 4.688 10.688 60.53-42.063 39.376 18.688 32.124 58.656-17.813 3.155 4.158L53.53 493.094h78.345l85.344-21.53 52.686 21.53h50.563l-7.064-45.78.813-.22-.97-.625-4.75-30.72c-6.82-2.366-13.532-5.066-20.125-8.03l3.844 24.967-51-33.437 18.75-6.594a240.973 240.973 0 0 1-19.033-13.125l-19.375 6.814-39.625-26 5.813-7.375 15.156-9.658a242.342 242.342 0 0 1-24.53-35.03c-16-27.74-25.828-57.3-29.97-87.157zm21.906 153.97 29.094 19.06-61.062 21.5 31.97-40.56zm48.72 31.967 49.343 32.344-49.78 12.563-58.845-24.033 59.28-20.875zm-94.813 26.532 5.718 2.312 58.125 23.75L82.75 486.22l41.47-52.626zm170.968 18.312 5.187 33.438-52.625-21.47 45.813-11.562 1.625-.406z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M198 89 29.58 487h59.77L211.8 89H198zm102.2 0 122.4 398h59.8L314 89h-13.8zm-79.4 32-3.1 10h76.6l-3.1-10h-70.4zm-79.9 36-4.4 14h7.2l5.9-14h-8.7zm68.8 0-4.3 14h101.2l-4.3-14h-92.6zm152.7 0 5.9 14h7.8l-4.4-14h-9.3zm-251.7 50-6.1 18h16.3l7.6-18h-17.8zm83.6 0-5.5 18h134.4l-5.5-18H194.3zm189.2 0 7.6 18h16.5l-5.9-18h-18.2zM74.88 269l-7.1 26h23.47l10.95-26H74.88zm100.32 0-8 26h177.6l-8-26H175.2zm234.6 0 11 26h23.4l-7.1-26h-27.3zM43.03 345l-7.5 30h21.88l12.69-30H43.03zm108.87 0-9.3 30h226.8l-9.3-30H151.9zm290 0 12.7 30h21.9l-7.5-30h-27.1zM19 441v24.8L29.49 441H19zm103.3 0-14.2 46h295.8l-14.2-46H122.3zm360.2 0 10.5 24.8V441h-10.5z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>

After

Width:  |  Height:  |  Size: 888 B

+1
View File
@@ -0,0 +1 @@
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M168.4 29.55 24.38 477.6l15.24 4.8L183.6 34.45l-15.2-4.9zm175.2 0-15.2 4.9 144 447.95 15.2-4.8-144-448.05zM248 32l-.8 20h17.6l-.8-20h-16zm-1.4 36-.8 20h20.4l-.8-20h-18.8zm-1.5 36-1.3 32h24.4l-1.3-32h-21.8zm-1.9 48-2 48h29.6l-2-48h-25.6zm-2.8 68-2.4 60h36l-2.4-60h-31.2zm-3.3 84-2.9 72h43.6l-2.9-72h-37.8zm-4 100-3.1 76h52l-3.1-76h-45.8z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>

After

Width:  |  Height:  |  Size: 596 B

+177 -36
View File
@@ -295,17 +295,35 @@ input:focus, select:focus { border-color: var(--accent); }
image-rendering: pixelated;
}
/* A ranged unit's bombardment: a red dashed line from the battery to the
target and a red ring around the target. Drawn on the layer below the entity
icons so the units themselves stay on top. */
.target-line {
/* A ranged unit's bombardment: a red dashed parabolic arc from the battery to
the target and a red ring around the target. Drawn on the layer below the
entity icons so the units themselves stay on top. */
.target-arcs {
position: absolute;
height: 0;
border-top: 3px dashed #ff2b2b;
transform-origin: 0 50%;
left: 0;
top: 0;
width: 1px;
height: 1px;
overflow: visible;
pointer-events: none;
}
.target-line {
fill: none;
stroke: #ff2b2b;
stroke-width: 3;
stroke-dasharray: 10 8;
stroke-linecap: round;
pointer-events: none;
opacity: 0.6;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7));
}
.target-shell {
fill: #ffdd66;
stroke: #ff2b2b;
stroke-width: 1;
pointer-events: none;
filter: drop-shadow(0 0 3px rgba(255, 90, 40, 0.9));
}
.target-ring {
position: absolute;
width: 46px;
@@ -316,8 +334,11 @@ input:focus, select:focus { border-color: var(--accent); }
border: 3px solid #ff2b2b;
border-radius: 50%;
pointer-events: none;
opacity: 0.6;
box-shadow: 0 0 6px rgba(255, 43, 43, 0.85);
}
/* A barrage the player is looking at stands out from the rest. */
.target-line.target-selected, .target-ring.target-selected { opacity: 1; }
/* Outlines the selected or inspected tile. The polygon is built in JS from the
same warped hexagon as the terrain, so it follows the geography. */
@@ -440,6 +461,33 @@ input:focus, select:focus { border-color: var(--accent); }
/* On a battlefield both stacks shrink to half size and face each other across
the battle icon. */
.unit.in-battle { transform: scale(0.5); }
/* A trench: a hatched overlay marking the dug-in tile. */
.trench-marker {
position: absolute;
width: 34px;
height: 30px;
margin-left: -17px;
margin-top: -15px;
background-image: repeating-linear-gradient(
45deg, rgba(90, 60, 30, 0.55) 0 3px, rgba(40, 26, 12, 0.55) 3px 6px
);
border: 1px solid rgba(0, 0, 0, 0.4);
pointer-events: none;
z-index: 3;
}
/* A military tile improvement marker. */
.tile-improvement-marker {
position: absolute;
width: 22px;
height: 22px;
margin-left: -11px;
margin-top: -11px;
pointer-events: none;
z-index: 3;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));
}
.tile-improvement-marker img { width: 100%; height: 100%; }
.battle-marker {
position: absolute;
width: 22px;
@@ -519,6 +567,43 @@ input:focus, select:focus { border-color: var(--accent); }
color: #ffd54a;
margin-right: 3px;
}
/* A discreet progress bar under the city name, showing the first queued build
(yellow) or unit (blue). Hidden while the city is idle. */
.city-progress {
position: absolute;
left: 50%;
bottom: 100%;
margin-bottom: 1px;
transform: translateX(-50%);
width: var(--city-size);
height: 3px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(0, 0, 0, 0.7);
border-radius: 2px;
overflow: hidden;
pointer-events: none;
z-index: 4;
}
.city-progress .fill { height: 100%; width: 0; background: #7fc4ff; }
.city-progress.building .fill { background: #ffd54a; }
/* One small square per building level, scattered around the city icon. */
.city-buildings {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
}
.city-building-square {
position: absolute;
width: 10%;
height: 10%;
transform: translate(-50%, -50%);
box-sizing: border-box;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0, 0, 0, 0.6);
border-radius: 1px;
}
.path-seg {
position: absolute;
@@ -579,6 +664,12 @@ input:focus, select:focus { border-color: var(--accent); }
.stat { display: flex; flex-direction: column; align-items: flex-end; }
.stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
.stat-value { font-size: 15px; font-variant-numeric: tabular-nums; }
/* Month-on-month change marker inside a stat: green up, red down, neutral for
unchanged, doubled for a move past the indicator's threshold. */
.stat-arrow { margin-left: 4px; font-size: 11px; font-weight: 700; letter-spacing: -1px; }
.stat-arrow.up { color: #6ee787; }
.stat-arrow.down { color: #f27272; }
.stat-arrow.equal { color: var(--muted); }
/* Approval reads white in the neutral 40–60% band, red when the people
disapprove and green when they approve. */
.stat-value.approval-neutral { color: var(--text); }
@@ -639,39 +730,33 @@ input:focus, select:focus { border-color: var(--accent); }
padding: 12px 18px;
}
/* The improvement picker opens directly above the build button. */
.build-anchor { position: relative; display: inline-block; }
.build-popover {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
min-width: 300px;
background: var(--panel-bg, rgba(18, 24, 34, 0.96));
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
padding: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
z-index: 70;
}
.build-popover-title {
font-size: 12px;
color: var(--muted);
margin: 2px 4px 8px;
}
.build-option {
/* One button per buildable tile improvement, laid out in a wrapping row. */
.tile-improvement-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
margin: 4px 0;
text-align: left;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.build-option .build-name { font-weight: 600; }
.build-option .build-meta { font-size: 11px; color: var(--muted); }
.build-option.current { outline: 1px solid rgba(255, 255, 255, 0.4); }
.improvement-btn { font-size: 12px; }
.improvement-btn.current { opacity: 0.6; }
.improvement-btn.remove { border-color: rgba(242, 114, 114, 0.6); }
/* Buildable improvements are square icons with the cost written beneath. */
.improvement-icon-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 64px;
padding: 5px 4px;
}
.improvement-icon { width: 40px; height: 40px; display: block; }
.improvement-cost { font-size: 10px; color: var(--muted); font-variant-numeric: tabular-nums; }
.info-section.hidden { display: none !important; }
.info-title { font-size: 17px; font-weight: 600; margin-bottom: 4px; }
.city-production-label { font-size: 12px; color: var(--muted); margin-top: 4px; }
.city-production-bar { height: 8px; margin-top: 4px; }
.city-production-bar > div { height: 100%; background: linear-gradient(180deg, #7fc4ff, #2f7fd0); }
.tile-coords { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; margin-bottom: 2px; }
.tile-gdp-head { font-size: 13px; font-variant-numeric: tabular-nums; }
.tile-gdp-toggle { cursor: pointer; }
@@ -685,6 +770,12 @@ input:focus, select:focus { border-color: var(--accent); }
#unit-health-fill { height: 100%; background: linear-gradient(180deg, #6fe08a, #2fa04d); }
#unit-health-label { margin-top: 6px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.unit-cargo { margin-top: 6px; font-size: 12px; color: var(--muted); }
.unit-stack-list { display: flex; flex-direction: column; gap: 3px; margin-top: 8px; max-height: 140px; overflow-y: auto; }
.stack-unit { display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; }
.stack-unit-name { flex: 1; }
.stack-unit-hp { color: var(--muted); font-variant-numeric: tabular-nums; }
.unit-stats { display: flex; gap: 16px; margin-top: 6px; }
.unit-stat {
font-size: 13px;
@@ -960,6 +1051,19 @@ input:focus, select:focus { border-color: var(--accent); }
color: var(--muted);
}
.tab.active { background: rgba(110, 175, 255, 0.22); color: var(--text); }
/* Secondary tabs inside a page, e.g. the technology themes. */
.subtabs { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 18px 8px; }
.subtab {
cursor: pointer;
white-space: nowrap;
padding: 5px 12px;
border-radius: 7px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.25);
color: var(--muted);
font-size: 12px;
}
.subtab.active { background: rgba(110, 175, 255, 0.22); color: var(--text); }
.tab-page { display: none; }
/* The active tab fills the rest of the card and lets its body scroll: without
min-height: 0 the flex item would grow past the card and be clipped by the
@@ -1009,6 +1113,43 @@ input:focus, select:focus { border-color: var(--accent); }
.row-card .budget-detail { margin-top: 4px; }
.row-card .budget-line { font-size: 12px; color: var(--muted); margin-top: 2px; }
/* Technology cards are wide, short 3:1 tiles in a grid, so the tab shows many
more of them at once than a single stacked list would. */
#technology-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 8px;
align-content: start;
}
#technology-list .row-card {
margin-bottom: 0;
aspect-ratio: 3 / 1;
overflow: hidden;
padding: 8px 10px;
gap: 10px;
}
#technology-list .row-card .desc,
#technology-list .row-card .effects {
margin: 2px 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
#technology-list .row-card .actions { justify-content: flex-start; align-items: flex-start; }
#technology-list .row-card .actions .aero { min-width: 0; padding: 3px 12px; }
/* A focused technology fills a bar with the points committed beside it. */
#technology-list .cost { display: flex; align-items: center; gap: 8px; }
.tech-progress-track {
flex: 1 1 auto;
height: 8px;
background: rgba(255, 255, 255, 0.14);
border-radius: 4px;
overflow: hidden;
}
.tech-progress-fill { height: 100%; background: linear-gradient(90deg, #4f8cff, #7fd1ff); }
.tech-progress-text { white-space: nowrap; font-variant-numeric: tabular-nums; }
.row-card.conflict-card { display: block; cursor: pointer; }
.row-card.conflict-card:hover { background: rgba(255, 255, 255, 0.09); }
.conflict-detail {
+8 -7
View File
@@ -131,7 +131,7 @@
<div class="stat"><span class="stat-label">GDP</span><span id="stat-gdp" class="stat-value">€0</span></div>
<div class="stat"><span class="stat-label">GDP/capita</span><span id="stat-gdppc" class="stat-value">€0</span></div>
<div class="stat"><span class="stat-label">Budget</span><span id="stat-budget" class="stat-value">€0</span></div>
<div class="stat"><span class="stat-label">Approval</span><span id="stat-approval" class="stat-value approval-neutral">—</span></div>
<div class="stat"><span class="stat-label">Popularity</span><span id="stat-approval" class="stat-value approval-neutral">—</span></div>
</div>
</div>
<div id="fps-label">60 FPS</div>
@@ -154,15 +154,12 @@
<div id="tile-ethnicity"></div>
<div id="tile-gdp"></div>
<div id="tile-improvements"></div>
<div class="row">
<div class="build-anchor">
<div id="tile-build-popover" class="build-popover hidden"></div>
<button id="tile-build" type="button" class="aero hidden">Build improvement</button>
</div>
</div>
<div id="tile-works"></div>
<div id="tile-improvement-actions" class="tile-improvement-actions"></div>
</div>
<div id="city-info" class="info-section hidden">
<div id="city-name" class="info-title"></div>
<div id="city-production"></div>
<div class="row">
<button id="city-manage" class="aero">City</button>
</div>
@@ -210,8 +207,11 @@
<div class="health-bar"><div id="unit-health-fill"></div></div>
<span id="unit-health-label" class="health-label"></span>
</div>
<div id="unit-cargo" class="unit-cargo hidden"></div>
<div id="unit-stack-list" class="unit-stack-list hidden"></div>
<div class="row">
<button id="unit-pillage" class="aero hidden">Pillage</button>
<button id="unit-trench" class="aero hidden">Dig trench</button>
</div>
</div>
</div>
@@ -319,6 +319,7 @@
</div>
<div class="tab-page" id="tab-technology">
<div id="technology-sub" class="modal-sub"></div>
<div id="technology-themes" class="subtabs"></div>
<div id="technology-list" class="modal-body"></div>
</div>
<div class="tab-page" id="tab-diplomacy">
+55 -11
View File
@@ -28,8 +28,6 @@ export class GameScreen {
this.selectedCityId = 0;
// The tile whose info panel is open, if any (right-click inspection).
this._inspectedTile = null;
// The tile the improvement picker currently targets.
this._buildTarget = null;
this._trainingCityId = 0;
this._cityBuildings = new Map();
this._population = new Map();
@@ -84,7 +82,6 @@ export class GameScreen {
this.capabilityModal = new CapabilityModal();
this.feed = new GameFeed($("#game-feed"));
this.$stackMenu = $("#stack-menu");
this.$buildPopover = $("#tile-build-popover");
this.$statTooltip = $("#stat-tooltip");
this._hoveredStat = null;
@@ -201,6 +198,8 @@ export class GameScreen {
this._updateFeed(this._news);
this.map.setCatalogue(this.civilisations, this.protoUnits);
// Mirror the viewer's repeatable-research bonuses used by prediction.
this.map.railSpeed = (state.viewerStats || {}).railSpeed || 0;
this.map.applySnapshot(state);
// Refresh an active economic map mode against the new figures.
this._refreshEconomicMode();
@@ -280,21 +279,22 @@ export class GameScreen {
return data && data.civ === this.localCiv;
});
this._selectUnits(stack.length > 0 ? stack : [unit.id]);
// Clicking a unit also reveals the tile (or city) it stands on.
this._inspectUnitTile(unit);
};
this.map.onCitySelected = (id) => {
const city = (this.snapshot.cities || []).find((c) => c.id === id);
if (!city || city.civ !== this.localCiv) return;
this.selectedCityId = id;
this.selectedUnitIds = [];
this.map.setSelectedUnits([]);
this._hideStackMenu();
this._showCity(city);
this.map.onCitySelected = (id) => this._selectCity(id);
// A double click goes one step further and opens the full city panel.
this.map.onCityOpened = (id) => {
if (this._selectCity(id)) this._openCityModal();
};
this.map.onTileRequested = (coords) => this._showTile(coords);
this.map.onStackMenu = (coords, ids, x, y) => this._openStackMenu(coords, ids, x, y);
this.map.onMoveOrdered = (unitIds, coords) => {
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
if (units.length === 0) return;
// Right-clicking is also how troops board a launch and how a loaded launch
// puts its passengers ashore.
if (this._tryTransportOrder(units, coords)) return null;
return this._orderMove(units, coords);
};
this.map.onAttackOrdered = (unitIds, coords) => {
@@ -309,6 +309,50 @@ export class GameScreen {
};
}
// A selected unit is a transport (launch) when its type carries capacity.
_isTransport(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.transportCapacity > 0);
}
// Right-click transport orders: infantry board a friendly launch they stand
// beside, and a loaded launch puts its passengers ashore on an adjacent land
// tile. Returns true when the click was consumed.
_tryTransportOrder(units, coords) {
const infantry = units.every((unit) => {
const proto = this.protoUnits[unit.proto];
return proto && proto.infantry;
});
if (infantry) {
const launch = (this.snapshot.units || []).find(
(unit) =>
unit.civ === this.localCiv &&
unit.coords[0] === coords.x &&
unit.coords[1] === coords.y &&
this._isTransport(unit)
);
if (launch) {
this.sendOrder({ type: "embark", units: units.map((unit) => unit.id), launch: launch.id });
return true;
}
}
const transports = units.every(
(unit) => this._isTransport(unit) && (unit.cargo || []).length > 0
);
if (transports) {
const tile = this.map.getTile(coords);
if (tile && tile.terrainClass === "Land") {
this.sendOrder({
type: "disembark",
units: units.map((unit) => unit.id),
coords: [coords.x, coords.y],
});
return true;
}
}
return false;
}
// Issues a move for the group, prompting first when the route would enter
// foreign territory at peace. `type` is "move", "attack" or "schedule".
async _orderMove(units, coords, type = "move") {
+444 -123
View File
@@ -4,7 +4,7 @@
// snapshot and forward orders.
import { describeNews } from "../modals.js";
import { compact, groupDigits, improvements as fmtImprovements } from "../../../shared/text_format.js";
import { compact, groupDigits, hours, improvements as fmtImprovements } from "../../../shared/text_format.js";
import {
CIVILISATIONS,
TRAINABLE_UNIT_IDS,
@@ -19,9 +19,11 @@ import {
pillageSteps,
ROADS,
TRANSPORT_IMPROVEMENTS,
transportImprovement,
TILE_IMPROVEMENTS,
AIR_STRIKE,
} from "../../../shared/data.js";
import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors } from "../../../shared/rules.js";
import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors, popularityDisplay } from "../../../shared/rules.js";
import { dateString } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
import { parseKey } from "../../../shared/hex.js";
@@ -77,8 +79,8 @@ export const panelMethods = {
this.sendOrder({ type: "build", city: cityId, building: index, free: true });
this.nationModal.onGovernment = (index) =>
this.sendOrder({ type: "government", government: index });
this.nationModal.onTechnology = (index) =>
this.sendOrder({ type: "technology", technology: index });
this.nationModal.onFocusTechnology = (index) =>
this.sendOrder({ type: "focus_technology", technology: index });
this.nationModal.onDeclareWar = (target) =>
this.sendOrder({ type: "declare_war", target });
this.nationModal.onCampaign = (observer, targetKind, target, direction) =>
@@ -104,33 +106,7 @@ export const panelMethods = {
.on("click", () => this._pillageSelected())
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
.on("mouseleave", () => this._hideStatTooltip());
// The improvement picker opens above the build button; the choices are
// delegated, so rebuilding the list never loses the handler.
$("#tile-build").on("click", (event) => {
event.stopPropagation();
this._toggleBuildPopover();
});
$("#tile-build-popover").on("click", ".build-choice", (event) => {
const $choice = $(event.currentTarget);
if ($choice.prop("disabled")) return;
// Recompute the target as a fallback, so a re-rendered popover can never
// lose the tile the choice applies to.
const target = this._buildTarget || this._buildableTile();
if (!target) return;
this._buildImprovement(
target.coords,
$choice.attr("data-improvement"),
$choice.attr("data-free") === "1"
);
this._hideBuildPopover();
});
$(document).on("mousedown.buildpopover", (event) => {
if (!$(event.target).closest("#tile-build-popover").length &&
!$(event.target).closest("#tile-build").length) {
this._hideBuildPopover();
}
});
$("#unit-trench").on("click", () => this._digTrenchSelected());
// A click anywhere outside the stack menu dismisses it.
$(document).on("mousedown.stackmenu", (event) => {
@@ -329,6 +305,36 @@ export const panelMethods = {
);
},
// Whether the selected units may dig in where they stand: every one is
// infantry, sharing one controlled land tile with no trench yet.
_canDigTrench(units) {
if (units.length === 0) return false;
const coords = units[0].coords;
if (!units.every((u) => u.coords[0] === coords[0] && u.coords[1] === coords[1])) return false;
if (!units.every((u) => {
const proto = this.protoUnits[u.proto];
return proto && proto.infantry;
})) return false;
const k = `${coords[0]},${coords[1]}`;
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
if (owner !== this.localCiv) return false;
const tile = this.map.getTile({ x: coords[0], y: coords[1] });
if (!tile || tile.terrainClass !== "Land") return false;
return !this.map.trenches.has(k);
},
_updateTrenchButton(units) {
const $button = $("#unit-trench");
if (!$button.length) return;
$button.toggleClass("hidden", !this._canDigTrench(units));
},
_digTrenchSelected() {
const units = this._selectedUnits();
if (!this._canDigTrench(units)) return;
this.sendOrder({ type: "dig_trench", units: units.map((unit) => unit.id) });
},
_isMilitaryUnit(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.military);
@@ -501,15 +507,68 @@ export const panelMethods = {
$("#admin-label").toggle(this.network.isAdmin);
$("#stat-date").text(dateString(state.totalHours));
const stats = state.viewerStats || {};
// The nation's figures one calendar month ago, when there is one, so each
// stat can show whether it rose or fell since.
const month = ((state.monthly || [])[this.localCiv]) || null;
$("#stat-pop").text(compact(stats.population || 0));
$("#stat-gdp").text(`€${compact(stats.gdp || 0)}`);
$("#stat-gdppc").text(`€${compact(stats.gdpPerCapita || 0)}`);
$("#stat-budget").text(`€${compact(stats.budget || 0)}`);
const approval = stats.approval === undefined || stats.approval === null ? 0.5 : stats.approval;
this._updateApprovalStat(approval);
// GDP, population and GDP/capita double the arrow past a 10% month-on-month
// move; the others only ever show one.
this._updateStatArrow("stat-pop", stats.population, month && month.population, 0.1);
this._updateStatArrow("stat-gdp", stats.gdp, month && month.gdp, 0.1);
this._updateStatArrow("stat-gdppc", stats.gdpPerCapita, month && month.gdpPerCapita, 0.1);
this._updateStatArrow("stat-budget", stats.budget, month && month.budget, null);
const popularityPoints = stats.popularity || 0;
const popularity = popularityDisplay(popularityPoints);
this._updateApprovalStat(popularity);
// The people drift one point an hour back to neutral, so the estimate is
// simply how many points away they are; zero is already there.
const pointsAway = Math.abs(popularityPoints);
$("#stat-approval").attr(
"title",
pointsAway <= 0
? "Popularity is exactly neutral"
: `Neutrality in about ${pointsAway} h (~${Math.ceil(pointsAway / 24)} d) ` +
"at the current drift of one point an hour"
);
// Popularity doubles its arrow past ten percentage points.
this._updateStatArrow(
"stat-approval",
popularity,
month ? popularityDisplay(month.popularity || 0) : null,
0.1,
0.005
);
},
// The summary's approval figure: white while it sits in the neutral band
// Appends the month-on-month change marker inside a stat's value: a bold
// green up arrow, a red down arrow, doubled once a big move occurred, or an
// equals sign when nothing changed. No baseline (January 2000) means none.
_updateStatArrow(statId, current, previous, doubleAt = null, equalEpsilon = 0.5) {
const $value = $(`#${statId}`);
if (!$value.length) return;
$value.find(".stat-arrow").remove();
if (previous === undefined || previous === null || current === undefined || current === null) {
return;
}
const diff = current - previous;
const $arrow = $("<span class='stat-arrow'></span>");
if (Math.abs(diff) <= equalEpsilon) {
$arrow.text("=").addClass("equal");
} else {
const rising = diff > 0;
const double = doubleAt !== null && previous !== 0 &&
Math.abs(diff) / Math.abs(previous) > doubleAt;
$arrow
.text(rising ? (double ? "▲▲" : "▲") : (double ? "▼▼" : "▼"))
.addClass(rising ? "up" : "down");
}
$value.append($arrow);
},
// The summary's popularity figure: white while it sits in the neutral band
// (40–60%), red when the people disapprove and green when they approve.
_updateApprovalStat(approval) {
const $approval = $("#stat-approval");
@@ -545,6 +604,8 @@ export const panelMethods = {
_showUnits(units) {
if (units.length === 0) {
$("#unit-info").addClass("hidden");
this._clearStackList();
this._updateCargo([]);
this._hideStatTooltip();
this._refreshPanelVisibility();
return;
@@ -559,18 +620,71 @@ export const panelMethods = {
$("#unit-attack, #unit-defense").addClass("hidden");
$("#unit-health-fill").css("width", `${maxHp > 0 ? Math.min(100, (hp / maxHp) * 100) : 0}%`);
$("#unit-health-label").text(`${Math.round(hp)} / ${Math.round(maxHp)} HP`);
this._renderStackList(units);
this._updatePillageButton(units);
this._updateTrenchButton(units);
this._updateCargo(units);
$("#unit-info").removeClass("hidden");
this._refreshPanelVisibility();
},
// The stack panel's checklist: every member of the selected stack, ticked,
// so the player can narrow the selection to the units they want to order.
_renderStackList(units) {
const $list = $("#unit-stack-list");
if (!$list.length) return;
$list.removeClass("hidden");
const signature = units.map((unit) => unit.id).join(",");
if (signature !== this._stackListSignature) {
this._stackListSignature = signature;
$list.empty();
for (const unit of units) {
const proto = this.protoUnits[unit.proto];
const $row = $("<label class='stack-unit'></label>");
const $check = $("<input type='checkbox'>")
.attr("data-id", unit.id)
.prop("checked", true);
$check.on("change", () => {
const ids = [];
$list.find("input:checked").each((_index, element) => {
ids.push(Number($(element).attr("data-id")));
});
this._selectUnits(ids);
});
$row.append(
$check,
$("<span class='stack-unit-name'></span>").text(proto ? proto.name : "Unit"),
$("<span class='stack-unit-hp'></span>").text(
`${Math.round(unit.hp)}/${unit.maxHp}`
)
);
$list.append($row);
}
}
// Keep the hit-point readouts current even when the set did not change.
units.forEach((unit, index) => {
$list
.find(".stack-unit-hp")
.eq(index)
.text(`${Math.round(unit.hp)}/${unit.maxHp}`);
});
},
_clearStackList() {
const $list = $("#unit-stack-list");
if ($list.length) $list.addClass("hidden").empty();
this._stackListSignature = null;
},
_showUnit(unit) {
if (!unit) {
$("#unit-info").addClass("hidden");
this._clearStackList();
this._hideStatTooltip();
this._refreshPanelVisibility();
return;
}
this._clearStackList();
const proto = this.protoUnits[unit.proto];
$("#unit-name").text(this._unitLabel(unit, proto));
$("#unit-attack, #unit-defense").removeClass("hidden");
@@ -579,6 +693,8 @@ export const panelMethods = {
$("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`);
$("#unit-health-label").text(`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`);
this._updatePillageButton([unit]);
this._updateTrenchButton([unit]);
this._updateCargo([unit]);
$("#unit-info").removeClass("hidden");
this._refreshPanelVisibility();
},
@@ -630,11 +746,43 @@ export const panelMethods = {
}
},
// A transport shows its berths and who is aboard, and any boarding still
// under way; non-transports show nothing.
_updateCargo(units) {
const $cargo = $("#unit-cargo");
if (!$cargo.length) return;
const lines = [];
for (const unit of units) {
const proto = this.protoUnits[unit.proto];
if (!proto || !proto.transportCapacity) continue;
const cargo = unit.cargo || [];
const names = cargo
.map((passenger) => {
const passengerProto = this.protoUnits[passenger.proto];
return passengerProto ? passengerProto.name : "troops";
})
.join(", ");
let text = `Cargo ${cargo.length}/${proto.transportCapacity}`;
if (names) text += ` — ${names}`;
if (unit.embarking && unit.embarking.count) {
text += ` · boarding ${unit.embarking.count} (${Math.ceil(unit.embarking.hoursLeft)} h)`;
}
lines.push(text);
}
if (lines.length === 0) {
$cargo.addClass("hidden").text("");
return;
}
$cargo.removeClass("hidden").text(lines.join(" · "));
},
// A city panel shows the tile it stands on (owner, terrain, population, GDP
// per capita, improvements) plus the city's name and its actions; the tile
// fields are the same ones the plain tile panel renders, so nothing is
// duplicated between the two.
_showCity(city) {
// `keepUnits` leaves the unit panel open, so clicking a unit that stands on a
// city shows the city's tile detail without dropping the unit selection.
_showCity(city, { keepUnits = false } = {}) {
if (!city) {
$("#city-info").addClass("hidden");
$("#tile-info").addClass("hidden");
@@ -644,12 +792,46 @@ export const panelMethods = {
this._inspectedTile = null;
this._renderTileInfo({ x: city.coords[0], y: city.coords[1] });
$("#city-name").text(city.name);
$("#unit-info").addClass("hidden");
$("#city-manage").toggleClass("hidden", city.civ !== this.localCiv);
if (!keepUnits) $("#unit-info").addClass("hidden");
$("#tile-info, #city-info").removeClass("hidden");
this._refreshPanelVisibility();
this._updateTileHighlight();
},
// Selects one of the player's cities and shows its quick panel. Returns false
// when the city is unknown or someone else's. Double-clicking the city icon
// uses the same selection before opening the full city screen.
_selectCity(id) {
const city = (this.snapshot.cities || []).find((c) => c.id === id);
if (!city || city.civ !== this.localCiv) return false;
this.selectedCityId = id;
this.selectedUnitIds = [];
this.map.setSelectedUnits([]);
this._hideStackMenu();
this._showCity(city);
return true;
},
// Clicking a unit opens the panel for the tile it stands on (or the city it
// occupies), alongside the unit panel rather than replacing it.
_inspectUnitTile(unit) {
const coords = { x: unit.coords[0], y: unit.coords[1] };
const city = (this.snapshot.cities || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
if (city) {
this._showCity(city, { keepUnits: true });
return;
}
this._inspectedTile = coords;
this._renderTileInfo(coords);
$("#city-info").addClass("hidden");
$("#tile-info").removeClass("hidden");
this._refreshPanelVisibility();
this._updateTileHighlight();
},
_showTile(coords) {
// Inspecting a tile drops any city or unit selection, otherwise the next
// snapshot would immediately switch the panel back to the old selection.
@@ -688,98 +870,206 @@ export const panelMethods = {
const cityImprovements = (city ? city.improvements || [] : []).filter(
(name) => name !== ROADS.improvementName && name !== "Railway"
);
// Military works and trenches are shown alongside the transport network.
const works = [];
if (this.map.trenches.has(k)) works.push("Trench");
const militaryWorks = this.map.tileImprovements.get(k);
if (militaryWorks) {
const proto = TILE_IMPROVEMENTS.find((entry) => entry.id === militaryWorks.id);
works.push(proto ? proto.name : militaryWorks.id);
}
$("#tile-improvements").text(
`Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport])))}`
`Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport, ...works])))}`
);
// Only a land tile the player controls can carry a new improvement.
const buildable = owner === this.localCiv &&
this._renderTileWorks(coords);
this._renderCityProduction(city);
// Only a land tile the player controls can carry a new improvement. A city
// improves itself, so it never offers the build buttons.
const buildable = !city &&
owner === this.localCiv &&
owner >= 0 &&
!!tile &&
tile.terrainClass === "Land";
$("#tile-build").toggleClass("hidden", !buildable);
if (!buildable) this._hideBuildPopover();
},
// The tile the improvement picker targets, or null when it cannot be built on.
_buildableTile() {
let coords = this._inspectedTile;
// A selected city still stands on an improvable tile.
if (!coords && this.selectedCityId) {
const city = this._city(this.selectedCityId);
if (city) coords = { x: city.coords[0], y: city.coords[1] };
}
if (!coords) return null;
const k = `${coords.x},${coords.y}`;
if ((this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1) !== this.localCiv) {
return null;
}
const tile = this.map.getTile(coords);
if (!tile || tile.terrainClass !== "Land" || this.localCiv < 0) return null;
return { coords, k, tile };
},
_toggleBuildPopover() {
if (!this.$buildPopover || !this.$buildPopover.length) return;
if (!this.$buildPopover.hasClass("hidden")) {
this._hideBuildPopover();
return;
}
this._renderBuildPopover();
},
// Lists the transport improvements with their price and hourly upkeep, greying
// out the one already on the tile and anything the budget cannot afford.
_renderBuildPopover() {
const target = this._buildableTile();
const $pop = this.$buildPopover;
if (!target || !$pop || !$pop.length) return;
this._buildTarget = target;
$pop.empty();
$pop.append($("<div class='build-popover-title'></div>").text("Build transport"));
const current = this.map.railways.has(target.k)
const current = this.map.railways.has(k)
? "railway"
: this.map.roads.has(target.k)
: this.map.roads.has(k)
? "road"
: null;
this._renderImprovementActions(coords, buildable, current);
},
// The military improvement under construction on this tile, drawn with the
// same progress bar as a city's production.
_renderTileWorks(coords) {
const $el = $("#tile-works");
if (!$el.length) return;
$el.empty();
const queue = (this.snapshot.tileWorks || [])[this.localCiv] || [];
const work = queue.find(
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
);
if (!work) return;
const proto = TILE_IMPROVEMENTS.find((improvement) => improvement.id === work.id);
const name = proto ? proto.name : work.id;
const total = Math.max(work.totalHours || 0, 0.0001);
const elapsed = work.elapsedHours || 0;
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
const remaining = Math.max(0, Math.ceil(total - elapsed));
const $bar = $("<div class='progress city-production-bar'></div>");
$bar.append($("<div></div>").css("width", `${pct}%`));
$el.append(
$("<div class='city-production-label'></div>").text(`Building ${name} — ${remaining} h left`),
$bar
);
},
// The city panel's quick view of what is currently being produced there, so
// the player can read progress without opening the city modal.
_renderCityProduction(city) {
const $el = $("#city-production");
if (!$el.length) return;
$el.empty();
const queue = city ? this._trainingQueue(city.id) : null;
const entry = queue && queue[0];
if (!entry) return;
const building = entry.kind === "building";
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
const name = proto ? proto.name : (building ? "Building" : "Unit");
const verb = building ? "Building" : "Training";
const total = Math.max(entry.totalHours || 0, 0.0001);
const elapsed = entry.elapsedHours || 0;
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
const remaining = Math.max(0, Math.ceil(total - elapsed));
const $bar = $("<div class='progress city-production-bar'></div>");
$bar.append($("<div></div>").css("width", `${pct}%`));
$el.append(
$("<div class='city-production-label'></div>").text(`${verb} ${name} — ${remaining} h left`),
$bar
);
},
// A square icon button for one buildable improvement: the type's icon with
// its cost beneath, and the name (plus upkeep and build time) in the tooltip.
_improvementButton(improvement, { className, costLabel, title, disabled, current }) {
const classes = `aero improvement-btn improvement-icon-btn ${className || ""}`;
const $button = $(`<button type='button' class='${classes}'></button>`)
.attr("title", title)
.prop("disabled", disabled)
.toggleClass("current", current);
$button.append(
$("<img class='improvement-icon'>")
.attr("src", `assets/${improvement.icon}`)
.attr("alt", improvement.name),
$("<span class='improvement-cost'></span>").text(costLabel)
);
return $button;
},
// One square icon button per transport and military improvement, plus a
// control to remove the one already standing on the tile. Shown only on a
// controlled land tile; an unaffordable improvement is greyed out.
_renderImprovementActions(coords, buildable, current) {
const $row = $("#tile-improvement-actions");
if (!$row.length) return;
$row.empty();
if (!buildable) return;
const budget = (this.snapshot.viewerStats || {}).budget || 0;
const testing = !!(this.snapshot && this.snapshot.testing);
for (const improvement of TRANSPORT_IMPROVEMENTS) {
const isCurrent = current === improvement.id;
const affordable = budget >= improvement.buildCost;
const $button = $("<button type='button' class='aero build-option build-choice'></button>");
$button.attr("data-improvement", improvement.id);
$button.append(
$("<div></div>").append(
$("<div class='build-name'></div>").text(improvement.name),
$("<div class='build-meta'></div>").text(
`€${groupDigits(improvement.buildCost)} · €${groupDigits(improvement.maintenance)}/tile·h`
)
),
$("<div class='build-meta'></div>").text(
isCurrent ? "Built" : `${Math.round(1 / improvement.movementCostMultiplier)}× grass`
)
);
$button.prop("disabled", isCurrent || !affordable);
$button.attr(
"title",
isCurrent
? "Already built on this tile"
: affordable
? `Build ${improvement.name} here`
: `Not enough budget (€${groupDigits(improvement.buildCost)} needed)`
);
if (isCurrent) $button.addClass("current");
$pop.append($button);
const title = isCurrent
? `${improvement.name} — already built on this tile`
: affordable
? `${improvement.name} — €${groupDigits(improvement.maintenance)}/tile·h upkeep`
: `${improvement.name} — not enough budget (€${groupDigits(improvement.buildCost)} needed)`;
const $button = this._improvementButton(improvement, {
className: "transport-btn",
costLabel: `€${compact(improvement.buildCost)}`,
title,
disabled: isCurrent || !affordable,
current: isCurrent,
});
$button.on("click", () => this._buildImprovement(coords, improvement.id, false));
$row.append($button);
// A local test game may raise it for free and at once.
if (testing && !isCurrent) {
const $free = $("<button type='button' class='aero free-button build-choice'></button>");
$free.attr("data-improvement", improvement.id);
$free.attr("data-free", "1");
$free.text(`${improvement.name}: free & instant`);
$pop.append($free);
const $free = this._improvementButton(improvement, {
className: "free-button",
costLabel: "free",
title: `${improvement.name} — build free and instantly (testing)`,
disabled: false,
current: false,
});
$free.on("click", () => this._buildImprovement(coords, improvement.id, true));
$row.append($free);
}
}
$pop.removeClass("hidden");
if (current) {
const name = transportImprovement(current) ? transportImprovement(current).name : current;
const $remove = $("<button type='button' class='aero improvement-btn remove'></button>")
.text(`Remove ${name}`)
.on("click", () => this._removeImprovement(coords));
$row.append($remove);
}
// Military tile improvements. They cost money and take production time, so
// the tooltip names the expected hours alongside the upkeep.
const military = this.map.tileImprovements.get(`${coords.x},${coords.y}`) || null;
const gdp = (this.snapshot.viewerStats || {}).gdp || 0;
const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0;
for (const improvement of TILE_IMPROVEMENTS) {
if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
const built = !!(military && military.id === improvement.id);
const affordable = budget >= improvement.buildCost;
const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed);
const title = military && !built
? `${improvement.name} — another improvement already stands here`
: affordable
? `${improvement.name} — €${groupDigits(improvement.maintenance)}/h upkeep · ${hours(buildHours)} to build`
: `${improvement.name} — not enough budget (€${groupDigits(improvement.buildCost)} needed)`;
const $button = this._improvementButton(improvement, {
className: "tile-btn",
costLabel: `€${compact(improvement.buildCost)}`,
title,
disabled: !!military || !affordable,
current: built,
});
$button.on("click", () => this._buildTileImprovement(coords, improvement.id));
$row.append($button);
}
if (military) {
const name = (TILE_IMPROVEMENTS.find((i) => i.id === military.id) || {}).name || military.id;
const $remove = $("<button type='button' class='aero improvement-btn remove'></button>")
.text(`Remove ${name}`)
.on("click", () => this._removeTileImprovement(coords));
$row.append($remove);
}
},
// Whether a land tile touches the sea, so a coastal cannon may be raised.
_tileIsCoastal(coords) {
if (!this.map.topology) return false;
for (const neighbour of this.map.topology.neighbours(coords.x, coords.y)) {
const tile = this.map.getTile(neighbour);
if (tile && tile.terrainClass === "Sea") return true;
}
return false;
},
_buildTileImprovement(coords, id) {
this.sendOrder({
type: "build_tile_improvement",
coords: [coords.x, coords.y],
improvement: id,
});
},
_removeTileImprovement(coords) {
this.sendOrder({
type: "remove_tile_improvement",
coords: [coords.x, coords.y],
});
},
_buildImprovement(coords, id, free = false) {
@@ -792,10 +1082,11 @@ export const panelMethods = {
this.sendOrder(order);
},
_hideBuildPopover() {
if (this.$buildPopover && this.$buildPopover.length) {
this.$buildPopover.addClass("hidden");
}
_removeImprovement(coords) {
this.sendOrder({
type: "remove_improvement",
coords: [coords.x, coords.y],
});
},
// A short, readable label for the tile: its owner's demonym, the terrain and
@@ -1099,6 +1390,10 @@ export const panelMethods = {
civs: this.civilisations,
ethnicMakeup: cityStats.ethnicMakeup || [],
approval: cityStats.approval,
// The region's tax base and approval price an upgrade's expected income.
base: cityStats.base || 0,
// Construction technology shortens the displayed build times.
constructionSpeed: stats.constructionSpeed || 0,
taxMultiplier: this._taxMultiplier(cityStats.approval),
};
},
@@ -1117,10 +1412,11 @@ export const panelMethods = {
const queue = this._trainingQueue(this.selectedCityId);
this.cityModal.renderQueue(queue);
this._updateCityProgress(queue);
const cityStats = this._cityStats(this.selectedCityId);
this.cityModal.constructionSpeed = stats.constructionSpeed || 0;
const city = this._city(this.selectedCityId);
if (city) {
this.cityModal.refreshTrainList(this._trainableUnits(city));
const cityStats = this._cityStats(this.selectedCityId);
this.cityModal.renderPeople(
cityStats.ethnicMakeup || [],
cityStats.approval,
@@ -1132,7 +1428,8 @@ export const panelMethods = {
stats.budget || 0,
this._cityBuildings.get(this.selectedCityId) || {},
stats.gdp || 0,
this._queuedBuildingLevels(this.selectedCityId)
this._queuedBuildingLevels(this.selectedCityId),
{ base: cityStats.base || 0, approval: cityStats.approval }
);
this.cityModal.renderBudget(this._cityBudget(this.selectedCityId));
},
@@ -1198,14 +1495,31 @@ export const panelMethods = {
this._researched,
stats.gdp || 0,
stats.budget || 0,
stats.research || 0,
stats.researchRate || 0,
this._finances(stats),
this._diplomacyInfo(),
this._conflicts,
this._politicsInfo()
this._politicsInfo(),
this._techInfo()
);
},
// The focused-research figures the technology tab reads from the snapshot.
_techInfo() {
const snapshot = this.snapshot || {};
const focus = (snapshot.focusTechnology || [])[this.localCiv];
const focusIndex = focus === undefined || focus === null ? -1 : focus;
// Progress is kept per technology, so a focus that was switched away from
// still shows what it had committed when it is selected again.
const byTech = (snapshot.techProgress || [])[this.localCiv] || {};
return {
focus: focusIndex,
progress: focusIndex >= 0 ? byTech[focusIndex] || 0 : 0,
progressByTech: byTech,
repeatCounts: (snapshot.repeatCounts || [])[this.localCiv] || {},
};
},
_updateNationModal() {
if (!this.snapshot || !this.nationModal.isShowing()) return;
const stats = this.snapshot.viewerStats || {};
@@ -1214,11 +1528,12 @@ export const panelMethods = {
this._researched,
stats.gdp || 0,
stats.budget || 0,
stats.research || 0,
stats.researchRate || 0,
this._finances(stats),
this._diplomacyInfo(),
this._conflicts,
this._politicsInfo()
this._politicsInfo(),
this._techInfo()
);
},
@@ -1249,12 +1564,18 @@ export const panelMethods = {
},
_openNewsModal() {
this.newsModal.show(this._news, this.civilisations);
this.newsModal.show(this._newsForModal(), this.civilisations);
},
_updateNewsModal() {
if (!this.snapshot || !this.newsModal.isShowing()) return;
this.newsModal.refresh(this._news, this.civilisations);
this.newsModal.refresh(this._newsForModal(), this.civilisations);
},
// The news list, without the repeatable-research notices: those still reach
// the player through the corner feed, but are not news events.
_newsForModal() {
return (this._news || []).filter((entry) => !entry.repeatable);
},
// Pops newly arrived news into the corner feed. The first snapshot only
+11
View File
@@ -100,6 +100,11 @@ export class MapView {
this.roads = new Set();
// Player-built railway tiles, keyed "x,y". A tile is a road or a railway.
this.railways = new Set();
// Trenches, and military improvements keyed "x,y" -> { id, owner, hp }.
this.trenches = new Set();
this.tileImprovements = new Map();
this._warfareSignature = null;
this._warfareViews = new Map();
this.civilisations = [];
this.localCiv = -1;
this.protoUnits = [];
@@ -114,6 +119,11 @@ export class MapView {
this.economicKind = null;
this.economicValues = new Map();
this.economicRange = { min: 0, max: 0 };
// Value transform for the active economic mode (population reads logs).
this.economicScale = null;
// The viewer's trains-technology rail bonus, mirrored so predicted movement
// along railways matches the server.
this.railSpeed = 0;
this._economicSignatureDone = null;
// WebGL fog is rebuilt from the frame loop, coalesced (see `_flushFog`).
this._fogDirty = false;
@@ -181,6 +191,7 @@ export class MapView {
this.onUnitSelected = () => {};
this.onCitySelected = () => {};
this.onCityOpened = () => {};
this.onTileRequested = () => {};
this.onMoveOrdered = () => {};
this.onAttackOrdered = () => {};
+14 -4
View File
@@ -24,6 +24,9 @@ export const ECONOMIC_GRADIENT = [
"#00204d",
];
// A population map is dominated by a handful of huge cities, which would
// otherwise crush every smaller settlement to the bottom of the scale, so it is
// read logarithmically; GDP per capita stays linear.
export const ECONOMIC_MODES = {
gdp: {
id: "gdp",
@@ -34,6 +37,7 @@ export const ECONOMIC_MODES = {
id: "population",
label: "Population",
format: (value) => groupDigits(Math.round(value)),
scale: (value) => Math.log1p(Math.max(0, value)),
},
};
@@ -42,8 +46,14 @@ export function isEconomicMode(mode) {
}
// Where a value sits on the scale, clamped to 0..1. A flat range (every tile
// the same) sits in the middle so the map reads as a single mid colour.
export function economicFraction(value, min, max) {
// the same) sits in the middle so the map reads as a single mid colour. An
// optional `scale` maps the values first (the population mode reads its log).
export function economicFraction(value, min, max, scale = null) {
if (scale) {
value = scale(value);
min = scale(min);
max = scale(max);
}
if (!(max > min)) return 0.5;
const t = (value - min) / (max - min);
return Math.max(0, Math.min(1, t));
@@ -51,8 +61,8 @@ export function economicFraction(value, min, max) {
// The colour for a value, as a "#rrggbb" string, interpolated between the two
// gradient stops that bracket it.
export function economicColour(value, min, max, stops = ECONOMIC_GRADIENT) {
const t = economicFraction(value, min, max);
export function economicColour(value, min, max, stops = ECONOMIC_GRADIENT, scale = null) {
const t = economicFraction(value, min, max, scale);
const scaled = t * (stops.length - 1);
const index = Math.min(stops.length - 2, Math.floor(scaled));
const blend = scaled - index;
+124 -3
View File
@@ -3,6 +3,7 @@
import { key, mapToLocal, parseKey } from "../../../shared/hex.js";
import { statusById } from "../../../shared/data.js";
import { tileImprovementById } from "../../../shared/data/improvements.js";
import { BATTLE_SIDE_OFFSET, CAMERA_TILT } from "./constants.js";
import { hashEntries, pathProgress, nearestCorners, inwardOffset } from "./utils.js";
import { bootLog } from "../loading.js";
@@ -26,6 +27,7 @@ export const entityMethods = {
bootLog.step("Snapshot received from the server");
this.ensureTerrain(state.seed, state.mapConfig || null);
this.setImprovements(state.roads || [], state.railways || [], state.versions && state.versions.improvements);
this.setWarfare(state.trenches || [], state.tileImprovements || [], state.versions && state.versions.warfare);
bootLog.step("Mirrored the transport network");
this.localCiv = state.viewer;
this._applyTerritory(state.territory, state.versions && state.versions.territory);
@@ -34,7 +36,7 @@ export const entityMethods = {
this._applyKnowledge(state.explored, state.visible);
bootLog.step("Mirrored the fog of war");
this._applyBattles(state.battles);
this._syncCities(state.cities);
this._syncCities(state.cities, state.training);
this._syncUnits(state.units);
bootLog.step("Placed the cities and units");
if (!this._centered) this._centerOnCapital(state.cities);
@@ -78,6 +80,51 @@ export const entityMethods = {
}
},
// Mirrors the trenches and military improvements from the snapshot and draws
// one marker per tile. A signature keeps the markers from being rebuilt when
// nothing moved.
setWarfare(trenches, improvements, version = undefined) {
const signature = `${version}:${trenches.length}:${improvements.length}`;
if (version !== undefined && signature === this._warfareSignature) return;
this._warfareSignature = signature;
this.trenches = new Set(trenches.map((entry) => key(entry[0], entry[1])));
this.tileImprovements = new Map();
for (const [x, y, id, owner, hp] of improvements) {
this.tileImprovements.set(key(x, y), { id, owner, hp });
}
this._renderWarfare();
},
_renderWarfare() {
const container = this.$entities && this.$entities[0];
if (!container) return;
for (const view of this._warfareViews.values()) view.remove();
this._warfareViews.clear();
for (const k of this.trenches) this._addWarfareMarker(container, k, "trench", null);
for (const [k, info] of this.tileImprovements) {
this._addWarfareMarker(container, k, "improvement", info);
}
},
_addWarfareMarker(container, k, kind, info) {
const coords = parseKey(k);
const local = mapToLocal(coords.x, coords.y);
const el = document.createElement("div");
el.className = kind === "trench" ? "trench-marker" : "tile-improvement-marker";
if (kind === "improvement") {
const proto = tileImprovementById(info.id);
const img = document.createElement("img");
img.alt = proto ? proto.name : info.id;
img.src = `assets/${proto ? proto.icon : "icon_battle.svg"}`;
el.dataset.owner = String(info.owner);
el.appendChild(img);
}
el.style.left = `${local.x + this._wrapShift(local.x)}px`;
el.style.top = `${local.y * CAMERA_TILT}px`;
container.appendChild(el);
this._warfareViews.set(`${kind}:${k}`, el);
},
_applyTerritory(entries, version = undefined) {
if (version !== undefined) {
// A server version is exact and free to compare, unlike hashing the whole
@@ -206,7 +253,7 @@ export const entityMethods = {
this._refreshFog();
},
_syncCities(cities) {
_syncCities(cities, training = null) {
const seen = new Set();
this._cityCiv = new Map();
for (const data of cities) {
@@ -217,6 +264,8 @@ export const entityMethods = {
view = this._createCityView(data);
this._cityViews.set(id, view);
}
this._updateCityProgress(view, training && training[id]);
this._syncCityBuildings(view, data);
const coords = { x: data.coords[0], y: data.coords[1] };
this._cityCiv.set(key(coords.x, coords.y), data.civ);
const local = mapToLocal(coords.x, coords.y);
@@ -229,6 +278,8 @@ export const entityMethods = {
view.style.left = `${wrappedX}px`;
view.style.top = `${groundY}px`;
const owned = data.civ === this.localCiv;
view.dataset.cityId = String(id);
view.dataset.civ = String(data.civ);
const label = view._label;
label.textContent = data.name;
label.classList.toggle("capital", !!data.isCapital);
@@ -263,12 +314,28 @@ export const entityMethods = {
marker.style.backgroundImage = "url(assets/icon_modern_city.svg)";
const statuses = document.createElement("div");
statuses.className = "city-statuses";
// One small square per building level, scattered around the icon.
const buildings = document.createElement("div");
buildings.className = "city-buildings";
// A discreet bar under the city name showing the current build or training.
const progress = document.createElement("div");
progress.className = "city-progress";
const fill = document.createElement("div");
fill.className = "fill";
progress.appendChild(fill);
progress.style.display = "none";
view.appendChild(marker);
view.appendChild(buildings);
view.appendChild(progress);
view.appendChild(statuses);
marker.addEventListener("click", (event) => {
event.stopPropagation();
if (view.dataset.owned === "1") this.onCitySelected(data.id);
});
marker.addEventListener("dblclick", (event) => {
event.stopPropagation();
if (view.dataset.owned === "1") this.onCityOpened(data.id);
});
this.$entities[0].appendChild(view);
const label = document.createElement("div");
label.className = "city-label";
@@ -277,6 +344,57 @@ export const entityMethods = {
return view;
},
// The little progress bar under a city's name: the first queued unit or
// building and how far along it is. Hidden while the city is idle.
_updateCityProgress(view, queue) {
const bar = view.querySelector(".city-progress");
if (!bar) return;
const entry = queue && queue[0];
if (!entry) {
bar.style.display = "none";
return;
}
const total = Math.max(entry.totalHours || 0, 0.0001);
const elapsed = entry.elapsedHours || 0;
bar.style.display = "block";
bar.firstChild.style.width = `${Math.max(0, Math.min(100, (elapsed / total) * 100))}%`;
bar.classList.toggle("building", entry.kind === "building");
},
// One small square per building level around the city icon. The scatter is a
// hash of the map seed, the city and the square's index, so the same city
// always grows the same recognisable pattern.
_syncCityBuildings(view, data) {
const container = view.querySelector(".city-buildings");
if (!container) return;
const total = Object.values(data.buildings || {}).reduce(
(sum, level) => sum + (level || 0),
0
);
if (container._count === total) return;
container._count = total;
container.textContent = "";
for (let i = 0; i < total; i++) {
const square = document.createElement("div");
square.className = "city-building-square";
const angle = this._cityHash(data.id, i, 0) * Math.PI * 2;
const radius = 0.55 + this._cityHash(data.id, i, 1) * 0.4;
square.style.left = `${50 + Math.cos(angle) * radius * 100}%`;
square.style.top = `${50 + Math.sin(angle) * radius * 100}%`;
container.appendChild(square);
}
},
// A stable 0..1 pseudo-random value from the seed, a city id and an index.
_cityHash(id, index, salt) {
let h = (this.seed | 0) ^ Math.imul(id, 2654435761) ^
Math.imul(index + 1, 40503) ^ Math.imul(salt + 1, 2246822519);
h = Math.imul(h ^ (h >>> 15), 2246822519);
h = Math.imul(h ^ (h >>> 13), 3266489917);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
},
_syncUnits(units) {
const seen = new Set();
// Store every unit's authoritative data first, so motion prediction can
@@ -408,7 +526,10 @@ export const entityMethods = {
_currentTile(motion) {
if (motion.path.length >= 2) {
const index = pathProgress(motion).index;
return motion.path[Math.min(index, motion.path.length - 1)];
// A unit that has been ordered off a tile no longer belongs to that
// tile's stack: it is grouped with the tile it is heading to, so the
// stack splits the moment one of its members marches away.
return motion.path[Math.min(index + 1, motion.path.length - 1)];
}
return motion.coords;
},
+18 -2
View File
@@ -30,6 +30,9 @@ export const modeMethods = {
this.economicKind = kind;
this.economicValues = values instanceof Map ? values : new Map(values || []);
this.economicRange = { min: min || 0, max: max || 0 };
// The scale a mode reads its figures through (the population mode is
// logarithmic). Kept on the view so both renderers agree.
this.economicScale = (ECONOMIC_MODES[kind] || {}).scale || null;
const signature = this._economicSignature();
if (signature === this._economicSignatureDone) return;
this._economicSignatureDone = signature;
@@ -131,12 +134,22 @@ export const modeMethods = {
$legend.find("#map-legend-title").text(meta.label);
const { min, max } = this.economicRange;
$legend.find("#map-legend-min").text(meta.format(min));
$legend.find("#map-legend-mid").text(meta.format((min + max) / 2));
$legend.find("#map-legend-mid").text(meta.format(this._economicMidpoint(min, max, meta.scale)));
$legend.find("#map-legend-max").text(meta.format(max));
$legend.find("#map-legend-bar")
.css("background", `linear-gradient(to right, ${ECONOMIC_GRADIENT.join(", ")})`);
},
// The value whose scaled position is the midpoint of the ramp, so a
// logarithmic legend still labels the middle colour honestly.
_economicMidpoint(min, max, scale) {
if (scale && max > min) {
const mid = (scale(min) + scale(max)) / 2;
return Math.expm1(mid);
}
return (min + max) / 2;
},
// Paints one DOM terrain tile with its economic colour. Water and tiles with
// no figure stay neutral; the atlas mask still clips the hexagon.
_applyEconomicColour(div, tile, k) {
@@ -145,7 +158,10 @@ export const modeMethods = {
const value = this.economicValues.get(k);
colour = value === undefined
? NEUTRAL_LAND
: economicColour(value, this.economicRange.min, this.economicRange.max);
: economicColour(
value, this.economicRange.min, this.economicRange.max,
ECONOMIC_GRADIENT, this.economicScale
);
}
div.style.backgroundImage = "none";
div.style.backgroundColor = colour;
+83 -30
View File
@@ -198,26 +198,27 @@ export const motionMethods = {
return mapToLocal(motion.coords.x, motion.coords.y);
},
// Draws the red dashed line from every ranged attacker with a standing target
// to the target tile, and a red ring around the target. Both appear only
// while the attacker (or the unit taking the fire) is selected, and sit on a
// layer above the terrain but below the unit and city icons.
// Draws the red dashed barrage arc from every ranged attacker with a standing
// target to the target tile, and a red ring around the target. The shell lobs
// in a parabola rather than along the ground, and the dashes march and a
// glowing shell travels the curve so the direction is unmistakable. They stay
// visible so an ongoing barrage cannot be forgotten, and sit on a layer above
// the terrain but below the unit and city icons.
_drawStrikeTargets() {
if (!this.$targets || !this.$targets.length) return;
// Strike lines only appear for a selected attacker or target.
if (this._selectedUnitIds.size === 0) {
if (this.$targets[0].firstChild) this.$targets.empty();
return;
}
const fragment = document.createDocumentFragment();
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "target-arcs");
const now = Date.now();
let arcs = 0;
for (const [id, data] of this._unitData) {
if (!data.strikeTarget) continue;
const target = { x: data.strikeTarget[0], y: data.strikeTarget[1] };
const targetIds = this.unitIdsAt(target);
const highlighted =
this._selectedUnitIds.has(id) ||
// The selected attacker or target stands out from the standing barrages
// the player is not looking at.
const selected = this._selectedUnitIds.has(id) ||
targetIds.some((targetId) => this._selectedUnitIds.has(targetId));
if (!highlighted) continue;
const fromPoint = this._unitRenderPosition(id);
if (!fromPoint) continue;
const targetId = targetIds[0];
@@ -227,21 +228,36 @@ export const motionMethods = {
if (!toPoint) continue;
const a = this._wrappedPosition(fromPoint);
const b = this._wrappedPosition(toPoint);
const dx = b.x - a.x;
const dy = b.y - a.y;
const line = document.createElement("div");
line.className = "target-line";
line.style.left = `${a.x}px`;
line.style.top = `${a.y}px`;
line.style.width = `${Math.hypot(dx, dy)}px`;
line.style.transform = `rotate(${Math.atan2(dy, dx)}rad)`;
fragment.appendChild(line);
const distance = Math.hypot(b.x - a.x, b.y - a.y);
// A quadratic Bezier lobs the shell: the control point is lifted above the
// midpoint, its height compensated for the layer's Y foreshortening so the
// arc keeps its shape on screen.
const arc = Math.max(18, distance * 0.4) / CAMERA_TILT;
const cx = (a.x + b.x) / 2;
const cy = (a.y + b.y) / 2 - arc;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}`);
path.setAttribute("class", selected ? "target-line target-selected" : "target-line");
// Marching dashes flow from the battery towards the target.
path.style.strokeDashoffset = `${-((now / 22) % 18)}px`;
svg.appendChild(path);
// A glowing shell rides the curve to show exactly where it is going.
const t = (now / 1300) % 1;
const mt = 1 - t;
const shell = document.createElementNS("http://www.w3.org/2000/svg", "circle");
shell.setAttribute("class", "target-shell");
shell.setAttribute("cx", `${mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`);
shell.setAttribute("cy", `${mt * mt * a.y + 2 * mt * t * cy + t * t * b.y}`);
shell.setAttribute("r", "3.5");
svg.appendChild(shell);
arcs += 1;
const ring = document.createElement("div");
ring.className = "target-ring";
ring.className = selected ? "target-ring target-selected" : "target-ring";
ring.style.left = `${b.x}px`;
ring.style.top = `${b.y}px`;
fragment.appendChild(ring);
}
if (arcs > 0) fragment.insertBefore(svg, fragment.firstChild);
if (!fragment.childNodes.length) {
if (this.$targets[0].firstChild) this.$targets.empty();
return;
@@ -333,6 +349,7 @@ export const motionMethods = {
const civ = unitData.civ;
const targetCiv = this._targetCivFor(unitData, goal);
const occupancy = this._buildOccupancy(unitData.id);
const zoc = this._zoneOfControl(civ);
const enterCache = new Map();
const costCache = new Map();
const heuristicCache = new Map();
@@ -340,20 +357,27 @@ export const motionMethods = {
// Mirror GameState._fastestTileTravelHours: a railway (or a road) may
// undercut the cheapest terrain, and the heuristic must stay admissible if
// A* is to find it.
const railCost = unitData.civ === this.localCiv
? TRANSPORT_BY_ID.railway.movementCostMultiplier / (1 + (this.railSpeed || 0))
: TRANSPORT_BY_ID.railway.movementCostMultiplier;
const cheapest = Math.min(
this.terrainStats.minMovementCostMultiplier(),
TRANSPORT_BY_ID.road.movementCostMultiplier,
TRANSPORT_BY_ID.railway.movementCostMultiplier
railCost
);
const fastestHours = cheapest / (bestSpeed * CONTROLLED_SPEED_MULTIPLIER);
const iterator = new HexPathfinder().findPathIter(
start,
goal,
(coords) => this.topology.neighbours(coords.x, coords.y),
(coords, isGoal) => {
const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
(coords, isGoal, from) => {
const fromKey = from ? key(from.x, from.y) : "";
const k = `${key(coords.x, coords.y)}${isGoal ? ":goal" : ""}@${fromKey}`;
if (!enterCache.has(k)) {
enterCache.set(k, this._canEnter(civ, proto, coords, unitData.id, isGoal, occupancy, targetCiv));
enterCache.set(
k,
this._canEnter(civ, proto, coords, unitData.id, isGoal, occupancy, targetCiv, zoc, from)
);
}
return enterCache.get(k);
},
@@ -443,6 +467,23 @@ export const motionMethods = {
return index;
},
// Mirrors GameState._zoneOfControl: the tiles hostile ground forces deny to
// `civ`, so prediction routes around them the same way the server does.
_zoneOfControl(civ) {
const denied = new Set();
for (const [, data] of this._unitData) {
if (data.civ === civ) continue;
const proto = this.protoUnits[data.proto];
if (!proto || !proto.military || proto.air) continue;
const k = key(data.coords[0], data.coords[1]);
denied.add(k);
for (const neighbour of this.topology.neighbours(data.coords[0], data.coords[1])) {
denied.add(key(neighbour.x, neighbour.y));
}
}
return denied;
},
_unitCoords(id) {
const motion = this._unitMotion.get(id);
if (!motion) return null;
@@ -469,13 +510,19 @@ export const motionMethods = {
// hostile unit or city is only a valid destination (`isGoal`). Foreign
// territory is a wall too, except for the civilisation being targeted, so a
// route never cuts through a third party and provokes a war.
_canEnter(civ, proto, coords, moverId = 0, isGoal = false, occupancy = null, targetCiv = -1) {
_canEnter(civ, proto, coords, moverId = 0, isGoal = false, occupancy = null, targetCiv = -1, zoc = null, from = null) {
const k = key(coords.x, coords.y);
const air = !!(proto && proto.air);
const tile = this.tiles[k];
if (!tile) return false;
if (!air && !this.explored.has(k)) return false;
if (!proto.traversableTerrains.includes(tile.terrainClass)) return false;
// Mirrors the server's zone of control: never cross from one denied tile
// straight into another, except as a deliberate attack on the goal.
if (!air && !isGoal && zoc && from &&
zoc.has(key(from.x, from.y)) && zoc.has(key(k))) {
return false;
}
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
if (!air && !isGoal && owner >= 0 && owner !== civ && owner !== targetCiv) return false;
const occupants = occupancy ? (occupancy.get(k) || []) : this._unitsAtCoords(coords, moverId);
@@ -505,9 +552,15 @@ export const motionMethods = {
let pace = effective;
if (this.territory.get(k) === civ) pace *= CONTROLLED_SPEED_MULTIPLIER;
let multiplier;
if (this.railways.has(k)) multiplier = TRANSPORT_BY_ID.railway.movementCostMultiplier;
else if (this.roads.has(k)) multiplier = TRANSPORT_BY_ID.road.movementCostMultiplier;
else multiplier = this.terrainStats.movementCostMultiplier(coords);
if (this.railways.has(k)) {
multiplier = TRANSPORT_BY_ID.railway.movementCostMultiplier;
// Only the viewer's own trains technology is known to the browser.
if (civ === this.localCiv) multiplier /= 1 + (this.railSpeed || 0);
} else if (this.roads.has(k)) {
multiplier = TRANSPORT_BY_ID.road.movementCostMultiplier;
} else {
multiplier = this.terrainStats.movementCostMultiplier(coords);
}
return multiplier / pace;
},
};
+2 -1
View File
@@ -2041,6 +2041,7 @@ export class GLMapRenderer {
const topo = view.topology;
const period = periodUnits(topo);
const { min, max } = view.economicRange || { min: 0, max: 0 };
const scale = view.economicScale || null;
const values = view.economicValues;
const rowStart = [];
for (let y = topo.originY; y < topo.originY + topo.height; y++) {
@@ -2048,7 +2049,7 @@ export class GLMapRenderer {
for (let x = topo.originX; x < topo.originX + topo.width; x++) {
const value = values.get(key(x, y));
if (value === undefined) continue;
const rgb = parseColour(economicColour(value, min, max));
const rgb = parseColour(economicColour(value, min, max, undefined, scale));
const colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
this._pushTileFill(arr, x, y, colour, seed, period);
}
+44 -5
View File
@@ -3,7 +3,7 @@
// city's production queue, so the train tab shows both kinds of order.
import { groupDigits, hours } from "../../../shared/text_format.js";
import { ECONOMY, BUILDINGS } from "../../../shared/data.js";
import { ECONOMY, BUILDINGS, EFFECT_BUDGET_INCOME, effectValue } from "../../../shared/data.js";
import {
buildingBuildCost,
buildingRefund,
@@ -37,6 +37,8 @@ export class CityModal {
this.cityId = 0;
this.gdp = 0;
this.coastal = false;
// Construction-technology bonus, which shortens every displayed duration.
this.constructionSpeed = 0;
// Local test game: each order also offers a free, instant button.
this.testing = false;
this._levels = {};
@@ -71,6 +73,7 @@ export class CityModal {
this.cityId = city.id;
this.gdp = view.gdp || 0;
this.coastal = !!view.coastal;
this.constructionSpeed = view.constructionSpeed || 0;
this.testing = !!view.testing;
this.$title.text(`${city.name} — City`);
this._renderTrainList(this.gdp, view.trainable || []);
@@ -80,7 +83,10 @@ export class CityModal {
this.buildingRows = (view.buildings || []).map((proto, index) =>
this._makeBuildingRow(proto, index)
);
this.refreshBuildings(view.budget || 0, view.levels || {}, this.gdp, {});
this.refreshBuildings(view.budget || 0, view.levels || {}, this.gdp, {}, {
base: view.base || 0,
approval: view.approval,
});
this.$budgetSub.text("Region net +€0/h");
this.renderBudget(null);
this.renderPeople(view.ethnicMakeup || [], view.approval, view.taxMultiplier, view.civs || []);
@@ -113,6 +119,12 @@ export class CityModal {
// -------------------------------------------------------------- training --
// The fraction of the base build time a construction technology leaves, so a
// 1% level shows a 1% shorter build.
_constructionTimeFactor() {
return 1 / (1 + (this.constructionSpeed || 0));
}
_renderTrainList(gdp, trainableUnits) {
this.protoNames = new Map();
this.$trainList.empty();
@@ -126,7 +138,7 @@ export class CityModal {
$("<img class='unit-icon' />").attr({ src: `assets/${proto.icon}`, alt: proto.name })
);
}
const duration = ECONOMY.trainingHours(gdp, proto.cost);
const duration = ECONOMY.trainingHours(gdp, proto.cost) * this._constructionTimeFactor();
button.append(
$("<span></span>").text(`${proto.name} — Cost ${groupDigits(proto.cost)} — ${hours(duration)}`)
);
@@ -262,7 +274,32 @@ export class CityModal {
return null;
}
refreshBuildings(budget, levels, gdp = 0, queued = {}) {
// The upgrade button's tooltip: how much the next level is expected to add
// to the region's hourly tax, given its GDP base and the people's approval.
_setBuildTooltip($button, proto, level, nextLevel, region) {
if (!$button || !$button.length) return;
const base = region ? region.base || 0 : 0;
const approval = region && region.approval !== undefined && region.approval !== null
? region.approval
: 1;
let delta = 0;
for (const effect of proto.effects) {
if (effect.stat !== EFFECT_BUDGET_INCOME) continue;
delta += effectValue(effect, nextLevel) - effectValue(effect, level);
}
const amount = base * approval * delta;
if (!amount || Math.abs(amount) < 0.5) {
$button.removeAttr("title");
return;
}
const sign = amount >= 0 ? "+" : "−";
$button.attr(
"title",
`Expected budget income ${sign}€${groupDigits(Math.round(Math.abs(amount)))}/h`
);
}
refreshBuildings(budget, levels, gdp = 0, queued = {}, region = null) {
this._levels = levels || {};
this.$buildingsBudget.text(`Budget: €${groupDigits(Math.round(budget))}`);
for (const row of this.buildingRows) {
@@ -277,7 +314,7 @@ export class CityModal {
const parts = [];
if (level > 0) parts.push(`Upkeep €${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`);
const cost = buildingBuildCost(row.proto, nextLevel);
const duration = ECONOMY.trainingHours(gdp, cost);
const duration = ECONOMY.trainingHours(gdp, cost) * this._constructionTimeFactor();
parts.push(`Build cost €${groupDigits(Math.round(cost))} · ${hours(duration)}`);
const requirement = this._buildRequirement(row.proto);
if (requirement) parts.push(requirement);
@@ -285,11 +322,13 @@ export class CityModal {
row.$upgrade
.text(`Upgrade €${groupDigits(Math.round(cost))}`)
.prop("disabled", !!requirement || budget < cost);
this._setBuildTooltip(row.$upgrade, row.proto, level, nextLevel + 1, region);
if (row.$free) {
// Free does not ignore placement rules (a port still needs the sea).
row.$free
.text(`Free & instant — ${row.proto.name}`)
.prop("disabled", !!requirement);
this._setBuildTooltip(row.$free, row.proto, level, nextLevel + 1, region);
}
if (level > 0) {
row.$downgrade
+14
View File
@@ -14,6 +14,16 @@ export function formatEffect(effect, level) {
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} research/h`;
case "culture":
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} culture/h`;
case "infantry_attack":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% infantry attack`;
case "infantry_defense":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% infantry defense`;
case "artillery_attack":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% artillery attack`;
case "construction_speed":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% construction speed`;
case "rail_speed":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% railway speed`;
default:
return `${effect.stat} ${value.toFixed(2)}`;
}
@@ -55,6 +65,10 @@ export function describeNews(entry, civs) {
: name(entry.ethnicity);
return `${name(entry.civ)} enacted ${policyName} for ${who}`;
}
case "technology":
return entry.repeatable
? `${name(entry.civ)} improved ${entry.name}`
: `${name(entry.civ)} discovered ${entry.name}`;
case "join":
return `${entry.player} took control of ${name(entry.civ)}`;
default:
+68 -14
View File
@@ -5,7 +5,7 @@ import {
governmentChangeCost,
prerequisitesMet,
} from "../../../shared/rules.js";
import { POLICIES, PROPAGANDA, policyById } from "../../../shared/data.js";
import { POLICIES, PROPAGANDA, policyById, TECH_THEMES, technologyCost } from "../../../shared/data.js";
import { renderBudget } from "./budget.js";
import { effectsText, signedMoney } from "./format.js";
import { bindTabs } from "./tabs.js";
@@ -34,6 +34,9 @@ export class NationModal {
this.researched = new Set();
this.governmentRows = [];
this.technologyRows = [];
// Focused research: which theme tab is open and the current focus figures.
this.activeTheme = TECH_THEMES[0].id;
this.techInfo = { focus: -1, progress: 0, repeatCounts: {} };
this.diplomacy = null;
this.diplomacySort = { key: "name", dir: 1 };
this.conflicts = [];
@@ -51,7 +54,7 @@ export class NationModal {
this._politicsState = null;
this._politicsWizardOpen = false;
this.onGovernment = () => {};
this.onTechnology = () => {};
this.onFocusTechnology = () => {};
this.onDeclareWar = () => {};
this.onCampaign = () => {};
this.onCancelCampaign = () => {};
@@ -67,7 +70,7 @@ export class NationModal {
return !this.$modal.hasClass("hidden");
}
show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances, diplomacy = null, conflicts = [], politics = null) {
show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null) {
this.governments = governments;
this.technologies = technologies;
this.diplomacy = diplomacy;
@@ -80,10 +83,36 @@ export class NationModal {
this.$technologyList.empty();
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology"));
this.refresh(currentGovernment, researched, gdp, budget, research, finances, diplomacy, conflicts, politics);
this._buildTechnologyThemes();
this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo);
this.$modal.removeClass("hidden");
}
// The technology tab's theme sub-tabs, built once per open. Switching a tab
// only shows and hides the rows that are already there.
_buildTechnologyThemes() {
const $themes = $("#technology-themes");
if (!$themes.length) return;
$themes.empty();
for (const theme of TECH_THEMES) {
const $button = $("<button type='button' class='subtab'></button>").text(theme.name);
if (theme.id === this.activeTheme) $button.addClass("active");
$button.on("click", () => {
this.activeTheme = theme.id;
$themes.find(".subtab").removeClass("active");
$button.addClass("active");
this._applyTechnologyFilter();
});
$themes.append($button);
}
}
_applyTechnologyFilter() {
for (const row of this.technologyRows) {
if (row.$card) row.$card.toggle(row.proto.theme === this.activeTheme);
}
}
// The national budget, drawn by the shared table renderer. Building types
// expand to their per-city cost.
_renderBudget(breakdown) {
@@ -170,16 +199,17 @@ export class NationModal {
const $action = $("<button class='aero'></button>");
$action.on("click", () => {
if (kind === "government") this.onGovernment(index);
else this.onTechnology(index);
else this.onFocusTechnology(index);
});
$actions.append($action);
$card.append($info, $actions);
$list.append($card);
return { proto, index, kind, $name, $effects, $cost, $action };
return { proto, index, kind, $name, $effects, $cost, $action, $card };
}
refresh(currentGovernment, researched, gdp, budget, research, finances = {}, diplomacy = null, conflicts = null, politics = null) {
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null) {
this.currentGovernment = currentGovernment;
if (techInfo) this.techInfo = techInfo;
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
if (diplomacy !== null) this.diplomacy = diplomacy;
if (conflicts !== null) this.conflicts = conflicts || [];
@@ -187,7 +217,9 @@ export class NationModal {
this.$governmentSub.text(
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: €${groupDigits(Math.round(budget))}`
);
this.$technologySub.text(`Research points: ${groupDigits(Math.round(research))}`);
// Research has no pool: every point flows into the focused technology, so
// the panel shows the rate instead of a stockpile.
this.$technologySub.text(`Research: +${(researchRate || 0).toFixed(1)}/h`);
const breakdown = finances || {};
const net = breakdown.net || 0;
@@ -213,11 +245,16 @@ export class NationModal {
}
}
const repeatCounts = this.techInfo.repeatCounts || {};
for (const row of this.technologyRows) {
const proto = row.proto;
row.$name.text(proto.name);
row.$effects.text(effectsText(proto.effects, 1));
if (this.researched.has(row.index)) {
const repeat = repeatCounts[proto.id] || 0;
const focused = this.techInfo.focus === row.index;
row.$name.text(repeat > 0 ? `${proto.name} — Level ${repeat}` : proto.name);
// Show the effect of the next level, so a repeatable's diminishing gain
// is clear before it is chosen.
row.$effects.text(effectsText(proto.effects, repeat > 0 ? repeat : 1));
if (this.researched.has(row.index) && !proto.repeatable) {
row.$cost.text("Researched");
row.$action.text("Researched").prop("disabled", true);
} else if (!prerequisitesMet(proto, this.researched, this.technologies)) {
@@ -229,11 +266,28 @@ export class NationModal {
.join(", ");
row.$cost.text(`Requires: ${names}`);
row.$action.text("Locked").prop("disabled", true);
} else if (focused) {
// A focused technology fills a progress bar, with the committed and
// total points to its right.
const cost = technologyCost(proto, repeat);
const progress = this.techInfo.progress || 0;
const pct = cost > 0 ? Math.max(0, Math.min(100, (progress / cost) * 100)) : 0;
row.$cost.empty().append(
$("<div class='tech-progress-track'></div>").append(
$("<div class='tech-progress-fill'></div>").css("width", `${pct}%`)
),
$("<span class='tech-progress-text'></span>").text(
`${groupDigits(Math.round(progress))} / ${groupDigits(Math.round(cost))}`
)
);
row.$action.text("Focused").prop("disabled", true);
} else {
row.$cost.text(`Cost ${groupDigits(Math.round(proto.cost))} research points`);
row.$action.text(`Research ${groupDigits(Math.round(proto.cost))}`).prop("disabled", research < proto.cost);
const cost = technologyCost(proto, repeat);
row.$cost.text(`Cost ${groupDigits(Math.round(cost))} research points`);
row.$action.text("Focus").prop("disabled", false);
}
}
this._applyTechnologyFilter();
this._renderDiplomacy();
this._renderConflicts();
@@ -624,7 +678,7 @@ export class NationModal {
$("<div class='desc'></div>").text(
"Spend money to warm or cool a population's view of another people or government. " +
"Your cultural impact decides how strongly the message lands, and the last " +
"few percent are the hardest to win."
"few popularity points are the hardest to win."
)
);
$card.append(
+51 -4
View File
@@ -172,12 +172,24 @@ export class GameServer extends EventEmitter {
return this._handleBuild(civ, order);
case "build_improvement":
return this._handleBuildImprovement(civ, order);
case "remove_improvement":
return this._handleRemoveImprovement(civ, order);
case "dig_trench":
return this._handleDigTrench(civ, order);
case "build_tile_improvement":
return this._handleBuildTileImprovement(civ, order);
case "remove_tile_improvement":
return this._handleRemoveTileImprovement(civ, order);
case "embark":
return this._handleEmbark(civ, order);
case "disembark":
return this._handleDisembark(civ, order);
case "demolish":
return this._handleDemolish(civ, order);
case "government":
return this._handleGovernment(civ, order);
case "technology":
return this._handleTechnology(civ, order);
case "focus_technology":
return this._handleFocusTechnology(civ, order);
case "declare_war":
return this._handleDeclareWar(civ, order);
case "campaign":
@@ -292,12 +304,47 @@ export class GameServer extends EventEmitter {
);
}
// Tears a road or railway off a tile the player controls.
_handleRemoveImprovement(civ, order) {
return this.state.requestRemoveImprovement(civ, coordsOf(order.coords));
}
_handleDigTrench(civ, order) {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
return this.state.requestDigTrench(units);
}
_handleBuildTileImprovement(civ, order) {
return this.state.requestBuildTileImprovement(
civ,
coordsOf(order.coords),
String(order.improvement || "")
);
}
_handleRemoveTileImprovement(civ, order) {
return this.state.requestRemoveTileImprovement(civ, coordsOf(order.coords));
}
_handleEmbark(civ, order) {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
return this.state.requestEmbark(units, Number(order.launch));
}
_handleDisembark(civ, order) {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
return this.state.requestDisembark(units, coordsOf(order.coords));
}
_handleGovernment(civ, order) {
return this.state.requestSetGovernment(civ, Number(order.government));
}
_handleTechnology(civ, order) {
return this.state.requestResearchTechnology(civ, Number(order.technology));
_handleFocusTechnology(civ, order) {
return this.state.requestFocusTechnology(civ, Number(order.technology));
}
_handleDeclareWar(civ, order) {
+2 -2
View File
@@ -9,7 +9,7 @@ export const BUILDINGS = [
id: "market",
name: "Market",
description: "A hub of local trade. Raises the GDP per capita of the city's region, with diminishing returns.",
baseCost: 5_000_000,
baseCost: 500_000,
costAlpha: 1.6,
refundFraction: 0.5,
effects: [effect(EFFECT_GDP, 0.04, 0.75)],
@@ -19,7 +19,7 @@ export const BUILDINGS = [
id: "bank",
name: "Bank",
description: "Channels private wealth into the state coffers. Raises the tax collected from the city's region.",
baseCost: 10_000_000,
baseCost: 1_000_000,
costAlpha: 1.7,
refundFraction: 0.5,
effects: [effect(EFFECT_BUDGET_INCOME, 0.05, 0.7)],
+17
View File
@@ -14,6 +14,8 @@ export const BATTLE = {
gdpPerCapitaLossPerDay: 0.3,
recoveryDelayDays: 30,
recoveryPerDay: 100,
// Structures on a battlefield take this much damage each day of fighting.
structureDamagePerDay: 120,
};
// Pillaging: each unit removes this fraction of the tile's remaining
@@ -25,6 +27,15 @@ export const PILLAGE = {
healPerPeople: 1000,
};
// Trenches: infantry dug into a tile are much harder to dislodge, at the cost
// of the local economy. The defense figures are multipliers on a unit's base
// defense; melee attackers get no bonus, artillery is heavily blunted.
export const TRENCH = {
gdpMultiplier: 0.8,
meleeDefenseMultiplier: 2.0,
artilleryDefenseMultiplier: 3.0,
};
// Sustained bombardment: a ground or naval battery ordered onto a target fires
// one volley every in-game hour. Each volley deals this fraction of the damage
// a single strike would (attack minus the defender's effective defense), so a
@@ -33,6 +44,12 @@ export const BOMBARD = {
hourlyDamageFraction: 0.1,
};
// Bombarding a city that has no garrison left: each point of damage kills this
// many people and leaves the usual battle scar on its tile.
export const CITY_BOMBARD = {
populationPerDamage: 10,
};
// Aircraft sorties: a plane flies to its target, bombs once, returns to a
// friendly airport and then cannot strike again for `cooldownHours` (two days).
// Each attacking aircraft costs `cost` from its nation's budget, charged up
+7
View File
@@ -6,6 +6,13 @@ export const EFFECT_GDP = "gdp";
export const EFFECT_RESEARCH = "research";
export const EFFECT_CULTURE = "culture";
export const EFFECT_BUDGET_INCOME = "budget_income";
// Repeatable technology effects. Each is a fraction that stacks with every
// further level researched.
export const EFFECT_INFANTRY_ATTACK = "infantry_attack";
export const EFFECT_INFANTRY_DEFENSE = "infantry_defense";
export const EFFECT_ARTILLERY_ATTACK = "artillery_attack";
export const EFFECT_CONSTRUCTION_SPEED = "construction_speed";
export const EFFECT_RAIL_SPEED = "rail_speed";
export function effect(stat, baseValue, exponent = 1.0) {
return { stat, baseValue, exponent };
+56
View File
@@ -15,6 +15,7 @@ export const TRANSPORT_IMPROVEMENTS = [
movementCostMultiplier: 0.5,
buildCost: 50_000_000,
maintenance: 100_000,
icon: "icon_road.svg",
},
{
id: "railway",
@@ -23,6 +24,7 @@ export const TRANSPORT_IMPROVEMENTS = [
movementCostMultiplier: 0.25,
buildCost: 150_000_000,
maintenance: 300_000,
icon: "icon_railway.svg",
},
];
@@ -33,3 +35,57 @@ export const TRANSPORT_BY_ID = Object.fromEntries(
export function transportImprovement(id) {
return TRANSPORT_BY_ID[id] || null;
}
// Player-buildable military tile improvements. Unlike transport, these take
// construction time (the same GDP-scaled formula as buildings and units) and
// cost an hourly maintenance. A tile may carry one of these plus a road or
// railway. `defense` is the extra fraction of base defense granted to units
// stationed there against melee and artillery fire.
export const TILE_IMPROVEMENTS = [
{
id: "fortifications",
name: "Fortifications",
description:
"Earthworks and bunkers. Holds a permanent area of control, shelters its garrison and takes damage in combat.",
buildCost: 200_000_000,
maintenance: 10_000,
vision: 2,
defense: { melee: 2.0, artillery: 4.0 },
permanentControl: true,
takesCombatDamage: true,
icon: "icon_fortifications.svg",
},
{
id: "radar_tower",
name: "Radar tower",
description:
"Watches five tiles out. A ranged hit damages it and an enemy walking onto it captures it.",
buildCost: 60_000_000,
maintenance: 1000,
vision: 5,
takesRangedDamage: true,
capturable: true,
icon: "icon_radar_tower.svg",
},
{
id: "coastal_cannon",
name: "Coastal cannon",
description:
"Automatically fires on hostile warships within two tiles. Only a coastal tile can carry one.",
buildCost: 120_000_000,
maintenance: 2000,
range: 2,
automatic: true,
takesDamage: true,
coastal: true,
icon: "icon_coastal_cannon.svg",
},
];
export const TILE_IMPROVEMENT_BY_ID = Object.fromEntries(
TILE_IMPROVEMENTS.map((improvement) => [improvement.id, improvement])
);
export function tileImprovementById(id) {
return TILE_IMPROVEMENT_BY_ID[id] || null;
}
+43 -9
View File
@@ -28,6 +28,34 @@ export const APPROVAL = {
taxFloorFraction: 0.1,
};
// Popularity is counted in integer points, centred on 0 (neutral). The whole
// 0..1 opinion scale maps to -100..+100 points, one point being half a percent.
// What the player sees is a logistic of those points: 0 points reads 50%, +100
// reads 75% and -100 reads 25%. Opinions drift one point an hour toward
// neutrality, and war, captures and casualties move them in points.
export const POPULARITY = {
opinionPerPoint: 0.005,
// Steepness chosen so display(0) = 0.5 and display(100) = 0.75.
logisticScale: Math.log(3) / 100,
// Points a population's opinion of its government moves toward neutral each
// hour, in either direction.
driftPerHour: 1,
// Declaring war costs the aggressor this many points with every population.
declareWar: 8,
// Being declared on grants the victim this many points with its own people,
// but only when the aggressor was already loathed there (below threshold).
declaredOnBoost: 15,
declaredOnThreshold: 0.15,
// Points won or lost per military unit destroyed, and per city taken.
unitLoss: 1,
unitKill: 1,
cityLoss: 10,
cityCapture: 10,
// Pillaging a tile costs the aggressor standing with the whole world, doubled
// among the populations directly affected.
pillage: 6,
};
// A campaign costs nothing to start: it charges `defaultHourlyCost` for as long
// as it runs, like any other upkeep and shown in the budget. The player may
// raise or lower the spending up to `maxHourlyCost`; the opinion shift scales
@@ -36,16 +64,20 @@ export const APPROVAL = {
// `culturalWeight` is how strongly the two nations' cultural impacts bend the
// result.
//
// Convincing the last few percent is the hardest: the daily shift is scaled by
// Convincing the last few points is the hardest: the daily shift is scaled by
// how much room is left before the opinion reaches its maximum (warming) or
// minimum (cooling), raised to `difficultyExponent`. The rate is unchanged at
// or below the neutral midpoint, so only the final approach to a devoted or
// hateful population suffers the diminishing returns that make the last
// percentage points disproportionately expensive.
//
// Opinions otherwise drift one point an hour toward neutral (0.12 a day), so a
// campaign must out-spend that drift to change minds; `dailyDelta` is set well
// above it.
export const PROPAGANDA = {
defaultHourlyCost: 8_000,
maxHourlyCost: 40_000,
dailyDelta: 0.012,
dailyDelta: 0.18,
culturalWeight: 0.6,
difficultyExponent: 2,
};
@@ -58,7 +90,9 @@ export const CAMPAIGN_COOL = -1;
// Minority policies a government may proclaim. Any number may be active at
// once, but the same policy may not be enacted twice for the same ethnicity.
// `targetEffect` and `politicalCost` are the estimates the wizard shows; the
// model applies them through POLICY_OPINION below.
// model applies them through POLICY_OPINION below. Effects are stated in
// popularity points (the integer unit the model works in); only the final
// approval figure is ever shown as a percentage.
export const POLICY_CHAMPION = "champion";
export const POLICY_EXPEL = "expel";
export const POLICY_PROTECT = "protect";
@@ -70,16 +104,16 @@ export const POLICIES = [
name: "Champion a minority",
description:
"Proclaim the state the protector of an ethnicity. Its people everywhere warm to you, but your own other populations resent the favouritism.",
targetEffect: "+15% approval among the chosen people",
politicalCost: "−8% approval among every other population in your country",
targetEffect: "+30 popularity among the chosen people",
politicalCost: "−16 popularity among every other population in your country",
},
{
id: POLICY_PROTECT,
name: "Protect a minority",
description:
"Shield an ethnicity inside your borders. It raises their view of you at home and abroad, at some cost with your other populations.",
targetEffect: "+12% approval among the chosen people",
politicalCost: "−4% approval among every other population in your country",
targetEffect: "+24 popularity among the chosen people",
politicalCost: "−8 popularity among every other population in your country",
},
{
id: POLICY_EXPEL,
@@ -87,7 +121,7 @@ export const POLICIES = [
description:
"Force an ethnicity out of the country. Its people, in and outside your borders, will hate you for a long time, and they will press into your neighbours.",
targetEffect: "The people begin to leave for neighbouring countries",
politicalCost: "−40% approval among the expelled people, remembered for a generation",
politicalCost: "−80 popularity among the expelled people, remembered for a generation",
},
{
id: POLICY_IMMIGRATION,
@@ -95,7 +129,7 @@ export const POLICIES = [
description:
"Invite an ethnicity in. They will come from lands where their government is unpopular, but every other population in your country dislikes the influx.",
targetEffect: "The chosen people migrate into your country",
politicalCost: "−5% approval among every other population in your country",
politicalCost: "−10 popularity among every other population in your country",
},
];
+3
View File
@@ -11,5 +11,8 @@ export const NEWS_WAR = "war";
export const NEWS_CAPTURE = "capture";
// A government enacted a minority policy. Recorded by the model.
export const NEWS_POLICY = "policy";
// A technology was discovered. Repeatable research levels raise these too, but
// flagged so the news list can keep them quiet while the feed still notifies.
export const NEWS_TECHNOLOGY = "technology";
// A player took control of a nation. Recorded by the server, not the model.
export const NEWS_JOIN = "join";
+97 -1
View File
@@ -1,13 +1,39 @@
// Research tree. A technology costs research points and may require earlier
// technologies before it can be started.
//
// Research is focused: a nation picks one technology, and every research point
// it creates is applied to it, month on month, until it is discovered. A
// `repeatable` technology may be researched again and again, each level costing
// `repeatCostGrowth` more than the last, and unlike a discovery it raises no
// news event (it only notifies the player).
import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
import {
EFFECT_GDP,
EFFECT_BUDGET_INCOME,
EFFECT_RESEARCH,
EFFECT_CULTURE,
EFFECT_INFANTRY_ATTACK,
EFFECT_INFANTRY_DEFENSE,
EFFECT_ARTILLERY_ATTACK,
EFFECT_CONSTRUCTION_SPEED,
EFFECT_RAIL_SPEED,
effect,
} from "./effects.js";
// The tabs the technology panel groups research under.
export const TECH_THEMES = [
{ id: "science", name: "Science" },
{ id: "economy", name: "Economy" },
{ id: "society", name: "Society" },
{ id: "military", name: "Military" },
];
export const TECHNOLOGIES = [
{
id: "scientific_method",
name: "Scientific Method",
description: "A disciplined approach to experiment and evidence. Lays the groundwork for every later technology.",
theme: "science",
cost: 20,
prerequisites: [],
effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_GDP, 0.02, 1.0)],
@@ -16,6 +42,7 @@ export const TECHNOLOGIES = [
id: "industrial_automation",
name: "Industrial Automation",
description: "Robotics and control systems take over the factory floor, lifting national output.",
theme: "economy",
cost: 60,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_GDP, 0.08, 1.0)],
@@ -24,6 +51,7 @@ export const TECHNOLOGIES = [
id: "renewable_energy",
name: "Renewable Energy",
description: "Clean generation cuts waste and strengthens the public finances.",
theme: "economy",
cost: 80,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_GDP, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)],
@@ -32,6 +60,7 @@ export const TECHNOLOGIES = [
id: "quantum_computing",
name: "Quantum Computing",
description: "Machines that reason in superposition. A step change in the pace of discovery.",
theme: "science",
cost: 120,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)],
@@ -40,12 +69,79 @@ export const TECHNOLOGIES = [
id: "space_program",
name: "Space Program",
description: "Reaching orbit inspires the nation and spins off technology across the economy.",
theme: "science",
cost: 300,
prerequisites: ["industrial_automation", "quantum_computing"],
effects: [effect(EFFECT_CULTURE, 3.0, 1.0), effect(EFFECT_GDP, 0.05, 1.0)],
},
// Repeatable levels. Each raises a single stat by 1% of its base value.
{
id: "infantry_weapons",
name: "Infantry Weapons",
description: "Better small arms and training raise the damage infantry deal.",
theme: "military",
cost: 40,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_INFANTRY_ATTACK, 0.01, 1.0)],
},
{
id: "body_armour",
name: "Body Armour",
description: "Personal protection raises infantry base defense.",
theme: "military",
cost: 40,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_INFANTRY_DEFENSE, 0.01, 1.0)],
},
{
id: "artillery_shells",
name: "Artillery Shells",
description: "Improved propellant and shells raise the damage artillery deal.",
theme: "military",
cost: 50,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_ARTILLERY_ATTACK, 0.01, 1.0)],
},
{
id: "construction_technology",
name: "Construction Technology",
description: "Better methods and machines speed every building and unit the nation produces.",
theme: "economy",
cost: 50,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_CONSTRUCTION_SPEED, 0.01, 1.0)],
},
{
id: "trains_technology",
name: "Trains Technology",
description: "Faster locomotives raise movement speed along railways.",
theme: "economy",
cost: 50,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_RAIL_SPEED, 0.01, 1.0)],
},
];
export function technologyById(id) {
return TECHNOLOGIES.find((t) => t.id === id) || null;
}
// The cost of the next level of a technology given how many times it has been
// researched already. Non-repeatable technologies cost the same either way.
export function technologyCost(proto, repeatCount = 0) {
if (!proto) return 0;
if (!proto.repeatable) return proto.cost;
const growth = proto.repeatCostGrowth || 0;
return proto.cost * Math.pow(1 + growth, Math.max(0, repeatCount));
}
+48
View File
@@ -14,6 +14,8 @@ export const PROTO_UNITS = [
moveable: true,
speed: 0.5,
military: true,
// Marks the unit for the infantry research effects (weapons, body armour).
infantry: true,
attack: 100,
defense: 40,
range: 1,
@@ -44,6 +46,8 @@ export const PROTO_UNITS = [
moveable: true,
speed: 0.25,
military: true,
// Marks the unit for the artillery research effect.
artillery: true,
attack: 250,
// Barely armoured: nearly every hit gets through.
defense: 5,
@@ -109,10 +113,52 @@ export const PROTO_UNITS = [
attack: 400,
defense: 120,
range: 2,
naval: true,
traversableTerrains: ["Sea"],
requiresBuilding: "shipyard",
icon: "icon_battleship.svg",
},
{
id: "mobile_radar",
name: "Mobile radar",
maxHp: 200,
cost: 50_000_000,
upkeep: 5000,
moveable: true,
speed: 0.5,
military: false,
attack: 0,
defense: 0,
range: 0,
// Watches four tiles out, cannot fight, is taken by any unit that walks
// onto it, and a lone one is destroyed outright by a single ranged hit.
vision: 4,
capturable: true,
destroyedByRangedAlone: true,
traversableTerrains: ["Land"],
icon: "icon_radar.svg",
},
{
id: "launch",
name: "Launch",
maxHp: 150,
cost: 20_000_000,
upkeep: 2000,
moveable: true,
speed: 0.5,
military: false,
attack: 0,
defense: 0,
range: 0,
// Transports one infantry unit and is defenceless on its own.
transportCapacity: 1,
transportKind: "infantry",
capturable: true,
naval: true,
traversableTerrains: ["Sea"],
requiresBuilding: "port",
icon: "icon_launch.svg",
},
];
// Only units in this list can be trained from the city screen.
@@ -122,6 +168,8 @@ export const TRAINABLE_UNIT_IDS = [
"jet_fighter",
"bomber",
"battleship",
"mobile_radar",
"launch",
];
export function protoUnitById(id) {
+41 -3
View File
@@ -44,6 +44,8 @@ import { politicsMethods } from "./game_state/politics.js";
import { orderMethods } from "./game_state/orders.js";
import { visibilityMethods } from "./game_state/visibility.js";
import { serializationMethods } from "./game_state/serialization.js";
import { monthlyMethods } from "./game_state/monthly.js";
import { warfareMethods } from "./game_state/warfare.js";
export {
MAX_NEWS,
@@ -104,6 +106,15 @@ export class GameState {
this.roads = new Set();
// Tiles carrying a railway. A tile holds a road or a railway, never both.
this.railways = new Set();
// War-time tile works: trenches dug by infantry, and military improvements
// (fortifications, radar towers, coastal cannons) with their owner and
// remaining hit points. A tile works queue per civilisation advances them.
this.trenches = new Set();
this.tileImprovements = new Map();
this.tileImprovementOwner = new Map();
this.tileImprovementHp = new Map();
this.tileWorks = new Map();
this._tileImprovementVersion = 0;
// Bumped whenever the transport network changes, so the serialised road and
// railway lists are rebuilt only when something actually moved.
this._improvementVersion = 0;
@@ -116,10 +127,18 @@ export class GameState {
this.tileBattleRecoveryStart = new Map();
this.budgets = new Map();
this.training = new Map();
this.research = new Map();
this.culture = new Map();
this.government = new Map();
this.researched = new Map();
// Focused research: each nation works on one technology at a time and every
// point it creates flows straight into that technology. There is no bank: a
// nation with no focus simply does not advance. `techProgress` keeps the
// points committed to every technology the nation has started, so switching
// focus never loses work already done, and `repeatCounts` counts how many
// times each repeatable technology has been researched.
this.focusTechnology = new Map();
this.techProgress = new Map();
this.repeatCounts = new Map();
this.explored = new Map();
this.visible = new Map();
// Diplomatic relations, stored as a set of canonical "a:b" keys of pairs
@@ -176,6 +195,9 @@ export class GameState {
this._cityAdjacent = new Map();
this._territoryByCiv = new Map();
this._gdpPerCapitaCache = new Map();
// Memoised nation-wide modifier totals, invalidated by _touchModifiers.
this._civModifierCache = new Map();
this._modifiersVersion = 0;
// Per-civ factors that map generation picked so every nation starts with
// the same population and GDP per capita (see _balanceStartingEconomy).
this._populationBalance = new Map();
@@ -184,6 +206,11 @@ export class GameState {
this._serializedExplored = new Map();
this._neighbourCache = new Map();
this._visibilityDirty = true;
// Month-over-month baseline for the nation summary bar; null until the
// world exists and January 2000 has a month to compare against.
this.monthlyStats = null;
this._monthIndexNow = 0;
this._currentMonthStats = null;
}
onChanged(fn) {
@@ -205,10 +232,12 @@ export class GameState {
this._resetCollections();
for (let i = 0; i < this.civilisations.length; i++) {
this.research.set(i, 0);
this.culture.set(i, 0);
this.government.set(i, 0);
this.researched.set(i, new Set());
this.focusTechnology.set(i, -1);
this.techProgress.set(i, new Map());
this.repeatCounts.set(i, new Map());
}
this.totalHours = 0;
this._configured = true;
@@ -217,6 +246,7 @@ export class GameState {
this._refreshStatuses();
this._revealAll();
this._refreshVisibility();
this._initMonthly();
this._visibilityDirty = false;
this._emitChanged();
}
@@ -267,10 +297,13 @@ export class GameState {
tickHour() {
this.totalHours += 1;
this._tickMonthly();
this._tickTraining();
this._tickAirUnits();
this._tickStrikes();
this._tickGarrisonHealing();
// Popularity drifts toward neutral every hour, not just daily.
this._tickOpinionDrift();
if (this._visibilityDirty) {
this._refreshVisibility();
this._visibilityDirty = false;
@@ -286,6 +319,9 @@ export class GameState {
this._resolveCombatRound();
}
this._tickBuildings();
this._tickTileWorks();
this._tickEmbark();
this._tickCoastalCannons();
// A unit no longer sharing a tile with an enemy is not an invader any more.
for (const unit of this.units) {
if (unit.invader && !this._hasHostileOn(unit.coords, unit.civ)) unit.invader = false;
@@ -309,5 +345,7 @@ Object.assign(
politicsMethods,
orderMethods,
visibilityMethods,
serializationMethods
serializationMethods,
monthlyMethods,
warfareMethods
);
+92 -25
View File
@@ -3,7 +3,7 @@
// the endurance model that keeps aircraft tied to a friendly airport.
import { key } from "../hex.js";
import { AIR_STRIKE, BOMBARD } from "../data/combat.js";
import { AIR_STRIKE, BOMBARD, CITY_BOMBARD } from "../data/combat.js";
export const airMethods = {
// ----------------------------------------------------- building rules --
@@ -164,14 +164,77 @@ export const airMethods = {
// ----------------------------------------------------------- strike --
_strikeTargetAt(coords, civ) {
const enemies = this.unitsAt(coords).filter(
(unit) => unit.civ !== civ && this._isMilitary(unit)
);
const enemies = this.unitsAt(coords).filter((unit) => {
if (unit.civ === civ) return false;
// Support units (mobile radar, launches) are legitimate targets for
// ranged fire even though they never fight back.
const proto = this.unitProto(unit);
return this._isMilitary(unit) || (proto && proto.capturable);
});
if (enemies.length === 0) return null;
enemies.sort((a, b) => (a.hp - b.hp) || (a.id - b.id));
// Infantry shelters the rest of a stack, so exposed support units are hit
// before dug-in troops.
enemies.sort((a, b) => {
const aExposed = this._isExposedTarget(a) ? 0 : 1;
const bExposed = this._isExposedTarget(b) ? 0 : 1;
return aExposed - bExposed || (a.hp - b.hp) || (a.id - b.id);
});
return enemies[0];
},
_isExposedTarget(unit) {
const proto = this.unitProto(unit);
return !!(proto && (proto.capturable || proto.artillery));
},
// An enemy city at the tile, the target of a bombardment when its garrison
// has already fallen.
_strikeCityAt(coords, civ) {
const city = this.cityAt(coords);
if (!city || city.civ === civ) return null;
return city;
},
// Ranged fire on a city with no garrison left: it kills people and leaves the
// usual battle scar, and the tile's structures take a share of the blow.
_bombardCity(city, amount) {
const k = key(city.coords.x, city.coords.y);
const population = this.tilePopulation.get(k) || 0;
const removed = Math.min(population, amount * CITY_BOMBARD.populationPerDamage);
if (removed > 0) {
this.tilePopulation.set(k, population - removed);
this._populationVersion += 1;
}
const perCapita = this.getTileGdpPerCapita(city.coords);
if (perCapita > 0) {
const deficit = (this.tileBattleGdpDeficit.get(k) || 0) + perCapita * 0.1;
this.tileBattleGdpDeficit.set(k, deficit);
this.tileBattleRecoveryStart.set(k, this.totalHours + 30 * 24);
this._gdpPerCapitaCache.delete(k);
}
this._damageTileImprovement(city.coords, Math.max(1, Math.round(amount * 0.1)));
this._emitChanged();
},
// Applies one ranged blow to a target unit, with the special fates of exposed
// support units, and passes a tenth of the damage on to any structures on the
// tile.
_applyStrikeDamage(target, damage, attackerProto = null) {
const proto = this.unitProto(target);
const alone = this.unitsAt(target.coords).length <= 1;
if (proto && proto.destroyedByRangedAlone && alone) damage = target.hp;
else if (proto && proto.naval && proto.capturable && alone) damage = target.hp;
target.hp -= damage;
// The tile's structures always take a tenth of an artillery or aerial blow.
this._damageTileImprovement(target.coords, Math.max(1, Math.round(damage * 0.1)));
if (target.hp > 0) return false;
target.hp = 0;
this._recordMilitaryCasualties([target]);
this._destroyUnit(target);
this._visibilityDirty = true;
return true;
},
// How far a unit can reach: an aircraft's mission radius, a ground battery's
// weapon range.
_strikeRange(proto) {
@@ -226,9 +289,10 @@ export const airMethods = {
if (units.length === 0) return false;
const civ = units[0].civ;
const target = this._strikeTargetAt(goal, civ);
if (!target) return false;
const city = target ? null : this._strikeCityAt(goal, civ);
if (!target && !city) return false;
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
this._declareWar(civ, target.civ);
this._declareWar(civ, target ? target.civ : city.civ);
for (const unit of units) {
this._stopUnit(unit);
unit.strikeTarget = { x: goal.x, y: goal.y };
@@ -245,7 +309,8 @@ export const airMethods = {
if (units.length === 0) return false;
const civ = units[0].civ;
const target = this._strikeTargetAt(goal, civ);
if (!target) return false;
const city = target ? null : this._strikeCityAt(goal, civ);
if (!target && !city) return false;
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
// Plan every flight before charging, so a rejected route costs nothing.
// `_strikeReady` above already proved the round trip fits the fuel; this
@@ -258,7 +323,7 @@ export const airMethods = {
}
const cost = (AIR_STRIKE.cost || 0) * units.length;
if (cost > 0 && !this._spendBudget(civ, cost)) return false;
this._declareWar(civ, target.civ);
this._declareWar(civ, target ? target.civ : city.civ);
units.forEach((unit, index) => {
unit.path = paths[index];
unit.pathIndex = 0;
@@ -283,12 +348,11 @@ export const airMethods = {
const target = this._strikeTargetAt(goal, unit.civ);
if (target) {
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
target.hp -= damage;
if (target.hp <= 0) {
target.hp = 0;
this._recordMilitaryCasualties([target]);
this._destroyUnit(target);
this._visibilityDirty = true;
this._applyStrikeDamage(target, damage, this.unitProto(unit));
} else {
const city = this._strikeCityAt(goal, unit.civ);
if (city) {
this._bombardCity(city, Math.max(1, this._unitAttack(unit)));
}
}
}
@@ -305,26 +369,29 @@ export const airMethods = {
if (!unit.strikeTarget || this._isAirUnit(unit)) continue;
const goal = unit.strikeTarget;
const target = this._strikeTargetAt(goal, unit.civ);
if (!target || !this._strikeReady(unit, goal)) {
if (!target) {
// No garrison left: the battery shells the city itself.
const city = this._strikeCityAt(goal, unit.civ);
if (city && this._strikeReady(unit, goal)) {
this._bombardCity(city, Math.max(1, Math.round(this._unitAttack(unit) * (BOMBARD.hourlyDamageFraction || 0) * 10)));
} else {
unit.strikeTarget = null;
}
continue;
}
if (!this._strikeReady(unit, goal)) {
unit.strikeTarget = null;
continue;
}
const full = this._unitAttack(unit) - this._effectiveDefense(target, true);
const full = this._unitAttack(unit) - this._effectiveDefense(target, true, this.unitProto(unit));
const amount = Math.max(1, Math.round(full * (BOMBARD.hourlyDamageFraction || 0)));
damage.set(target, (damage.get(target) || 0) + amount);
}
if (damage.size === 0) return;
const destroyed = [];
for (const [target, amount] of damage) {
target.hp -= amount;
if (target.hp <= 0) {
target.hp = 0;
destroyed.push(target);
}
if (this._applyStrikeDamage(target, amount, null)) destroyed.push(target);
}
this._recordMilitaryCasualties(destroyed);
for (const unit of destroyed) this._destroyUnit(unit);
if (destroyed.length > 0) this._visibilityDirty = true;
this._emitChanged();
},
+108 -9
View File
@@ -3,6 +3,12 @@
import { key } from "../hex.js";
import { BATTLE } from "../data/combat.js";
import { POPULARITY } from "../data/politics.js";
import {
EFFECT_INFANTRY_ATTACK,
EFFECT_INFANTRY_DEFENSE,
EFFECT_ARTILLERY_ATTACK,
} from "../data/effects.js";
import { STATUS_BATTLE, statusById } from "../data/statuses.js";
import { defenseMultiplier } from "../rules.js";
import { DEFENDER_DEFENSE_MULTIPLIER, HOURS_PER_DAY } from "./constants.js";
@@ -17,14 +23,30 @@ export const combatMethods = {
return !!(proto && proto.military);
},
// Base attack, lifted by the nation's infantry-weapons or artillery-shells
// research. Only the relevant stat for the unit's type applies.
_unitAttack(unit) {
const proto = this.unitProto(unit);
return proto && typeof proto.attack === "number" ? proto.attack : 0;
let attack = proto && typeof proto.attack === "number" ? proto.attack : 0;
if (proto && (proto.infantry || proto.artillery)) {
const modifiers = this.getCivModifiers(unit.civ);
const bonus = proto.infantry
? modifiers[EFFECT_INFANTRY_ATTACK]
: modifiers[EFFECT_ARTILLERY_ATTACK];
attack *= 1 + (bonus || 0);
}
return attack;
},
// Base defense, lifted by the nation's body-armour research for infantry.
_unitDefense(unit) {
const proto = this.unitProto(unit);
return proto && typeof proto.defense === "number" ? proto.defense : 0;
let defense = proto && typeof proto.defense === "number" ? proto.defense : 0;
if (proto && proto.infantry) {
const bonus = this.getCivModifiers(unit.civ)[EFFECT_INFANTRY_DEFENSE] || 0;
defense *= 1 + bonus;
}
return defense;
},
// Fraction of extra defense granted by the tile a unit occupies (terrain
@@ -34,12 +56,14 @@ export const combatMethods = {
return defenseMultiplier(tile, !!this.cityAt(coords));
},
// A unit's base defense, boosted by its tile, and doubled when it is the
// defending side of the exchange.
_effectiveDefense(unit, isDefender) {
// A unit's base defense, boosted by its tile and any fortifications or
// trenches there, and doubled when it is the defending side of the exchange.
// The attacker's type decides whether trench and fort bonuses use their melee
// or artillery figures.
_effectiveDefense(unit, isDefender, attackerProto = null) {
let defense = this._unitDefense(unit) * this._defenseMultiplierAt(unit.coords);
if (isDefender) defense *= DEFENDER_DEFENSE_MULTIPLIER;
return defense;
return defense * this._tileFortificationMultiplier(unit.coords, attackerProto);
},
// Unique attacker/defender pairs. A pair is two hostile military units on the
@@ -71,7 +95,13 @@ export const combatMethods = {
!this._isAirUnit(other)
);
if (enemies.length === 0) return null;
enemies.sort((a, b) => (a.hp - b.hp) || (a.id - b.id));
// Damage lands on infantry first, then the other fighting units, so a stack
// soaks blows with its footsoldiers before exposing support troops.
const rank = (unit) => {
const proto = this.unitProto(unit);
return proto && proto.infantry ? 0 : 1;
};
enemies.sort((a, b) => (rank(a) - rank(b)) || (a.hp - b.hp) || (a.id - b.id));
return enemies[0];
},
@@ -171,12 +201,14 @@ export const combatMethods = {
for (const { attacker, defender } of this._combatPairs()) {
const toDefender = Math.max(
1,
this._unitAttack(attacker) - this._effectiveDefense(defender, true)
this._unitAttack(attacker) -
this._effectiveDefense(defender, true, this.unitProto(attacker))
);
damage.set(defender, (damage.get(defender) || 0) + toDefender);
const toAttacker = Math.max(
1,
this._unitAttack(defender) - this._effectiveDefense(attacker, false)
this._unitAttack(defender) -
this._effectiveDefense(attacker, false, this.unitProto(defender))
);
damage.set(attacker, (damage.get(attacker) || 0) + toAttacker);
}
@@ -206,6 +238,9 @@ export const combatMethods = {
for (const unit of this.units) {
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
const coords = unit.coords;
// Support units and unaccompanied artillery are taken from underfoot.
this._captureSupportUnits(unit);
this._captureTileImprovement(coords, unit.civ);
if (this._hasHostileOn(coords, unit.civ)) continue;
const city = this._cityByCoords.get(key(coords.x, coords.y));
if (!city || city.civ === unit.civ || claimed.has(city.id)) continue;
@@ -214,6 +249,56 @@ export const combatMethods = {
}
},
// An enemy mobile radar, launch or unaccompanied artillery on the captor's
// tile changes hands, or is scuttled when the captor is a warship. Artillery
// escapes capture while any of its own infantry stands beside it.
_captureSupportUnits(captor) {
if (!captor || !this._isMilitary(captor) || this._isAirUnit(captor)) return false;
const captorProto = this.unitProto(captor);
const enemies = this.unitsAt(captor.coords).filter((unit) => unit.civ !== captor.civ);
if (enemies.length === 0) return false;
const enemyInfantry = enemies.some((unit) => {
const proto = this.unitProto(unit);
return proto && proto.infantry;
});
let changed = false;
for (const enemy of enemies) {
const proto = this.unitProto(enemy);
if (!proto) continue;
if (proto.capturable) {
// A warship over a launch stack simply sinks it, unprotected as it is.
if (proto.naval && captorProto && captorProto.naval) {
this._destroyUnit(enemy);
} else {
enemy.civ = captor.civ;
enemy.strikeTarget = null;
}
changed = true;
} else if (proto.artillery && captorProto && captorProto.infantry && !enemyInfantry) {
enemy.civ = captor.civ;
enemy.strikeTarget = null;
changed = true;
}
}
if (changed) {
this._visibilityDirty = true;
this._emitChanged();
}
return changed;
},
// Enemy aircraft caught on a captured city's airfield change allegiance.
_captureCityAircraft(city, newCiv) {
for (const unit of this.unitsAt(city.coords)) {
const proto = this.unitProto(unit);
if (!proto || !proto.air || unit.civ === newCiv) continue;
unit.civ = newCiv;
unit.homeCityId = city.id;
unit.strikeTarget = null;
}
this._visibilityDirty = true;
},
_destroyUnit(unit) {
const index = this.units.indexOf(unit);
if (index >= 0) this.units.splice(index, 1);
@@ -223,6 +308,14 @@ export const combatMethods = {
// Called when a unit finishes stepping onto a tile. A military unit entering
// an enemy city captures it, but only once every defender has fallen.
_onUnitArrived(unit) {
// Walking onto a defenceless support unit or unaccompanied artillery takes
// it, even while other enemies hold the tile.
this._captureSupportUnits(unit);
// A military unit also captures a defenceless enemy improvement underfoot,
// such as a radar tower, whether or not a fight is still going on.
if (this._isMilitary(unit) && !this._isAirUnit(unit)) {
this._captureTileImprovement(unit.coords, unit.civ);
}
if (this._hasHostileOn(unit.coords, unit.civ)) return;
const city = this._cityByCoords.get(key(unit.coords.x, unit.coords.y));
if (city && city.civ !== unit.civ && this._isMilitary(unit) && !this._isAirUnit(unit)) {
@@ -247,6 +340,10 @@ export const combatMethods = {
}
}
for (const enemy of enemies) this._recordCasualties(unit.civ, enemy, "military", 1);
// Losing a unit costs the owner standing at home; every enemy that was
// there to see it gains.
this._shiftOwnPopularity(unit.civ, -POPULARITY.unitLoss);
for (const enemy of enemies) this._shiftOwnPopularity(enemy, POPULARITY.unitKill);
}
},
@@ -289,6 +386,8 @@ export const combatMethods = {
this.totalHours + BATTLE.recoveryDelayDays * HOURS_PER_DAY
);
}
// Fortifications holding a battlefield are battered by the fighting.
this._damageTileImprovement(battle.coords, BATTLE.structureDamagePerDay);
}
},
+14
View File
@@ -7,6 +7,20 @@ export const MAX_NEWS = 100;
export const UNIT_VISION = 2;
export const TERRITORY_VISION = 1;
// Hit points of the military tile improvements, so they can be worn down by
// ranged fire before being knocked out.
export const TILE_IMPROVEMENT_HP = {
fortifications: 2000,
radar_tower: 600,
coastal_cannon: 800,
};
// A coastal cannon's hourly volley against a hostile warship.
export const COASTAL_CANNON = { damage: 120 };
// How long infantry take to board a launch.
export const EMBARK_HOURS = 24;
// The calendar lives in one place: re-exported so model code can keep
// importing every simulation constant from this module.
export { HOURS_PER_DAY, DAYS_PER_YEAR, HOURS_PER_YEAR } from "../game_clock.js";
+158 -16
View File
@@ -7,8 +7,12 @@ import {
EFFECT_BUDGET_INCOME,
EFFECT_RESEARCH,
EFFECT_CULTURE,
EFFECT_CONSTRUCTION_SPEED,
EFFECT_RAIL_SPEED,
} from "../data/effects.js";
import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.js";
import { technologyCost, technologyById } from "../data/technologies.js";
import { NEWS_TECHNOLOGY } from "../data/relations.js";
import {
buildingUpkeep,
gdpPerCapitaFactors,
@@ -74,6 +78,7 @@ export const economyMethods = {
pillagePenalty: this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1,
// Fighting scars the tile too, but that loss recovers over time.
battleDeficit: this.tileBattleGdpDeficit.get(k) || 0,
trench: this.trenches.has(k),
regionalMultiplier: 1 + ((regional && regional[EFFECT_GDP]) || 0),
regionalLabel: region ? region.name : null,
nationalMultiplier: 1 + (national[EFFECT_GDP] || 0),
@@ -103,8 +108,10 @@ export const economyMethods = {
}
// Running propaganda campaigns are upkeep too, paid hour by hour.
total += this.getCampaignUpkeep(civ);
// The transport network costs an hourly maintenance per tile held.
// The transport network and military tile improvements cost an hourly
// maintenance per tile and structure held.
total += this.getTransportUpkeep(civ);
total += this.getTileImprovementUpkeep(civ);
return total;
},
@@ -251,13 +258,21 @@ export const economyMethods = {
const campaignUpkeep = campaignSources.reduce((sum, source) => sum + source.amount, 0);
const transportSources = this._transportUpkeepSources(civ);
const transportUpkeep = transportSources.reduce((sum, source) => sum + source.amount, 0);
const tileImprovementSources = this._tileImprovementUpkeepSources(civ);
const tileImprovementUpkeep =
tileImprovementSources.reduce((sum, source) => sum + source.amount, 0);
return {
income: { rate, base, modifier, total: income, sources: incomeSources, regions },
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
unitUpkeep: { total: unitUpkeep, sources: unitSources, groups: unitSources },
campaignUpkeep: { total: campaignUpkeep, sources: campaignSources, groups: campaignSources },
transportUpkeep: { total: transportUpkeep, sources: transportSources, groups: transportSources },
net: income - buildingUpkeep - unitUpkeep - campaignUpkeep - transportUpkeep,
tileUpkeep: {
total: tileImprovementUpkeep,
sources: tileImprovementSources,
groups: tileImprovementSources,
},
net: income - buildingUpkeep - unitUpkeep - campaignUpkeep - transportUpkeep - tileImprovementUpkeep,
};
},
@@ -468,19 +483,40 @@ export const economyMethods = {
return totals;
},
// Nation-wide modifier totals, memoised because pathfinding reads the rail
// speed for every tile it expands and rebuilding the totals there is costly.
getCivModifiers(civ) {
const cached = this._civModifierCache.get(civ);
if (cached && cached.version === this._modifiersVersion) return cached.value;
const totals = this.getCivBuildingModifiers(civ);
mergeModifiers(totals, this.getCivGovernmentModifiers(civ));
mergeModifiers(totals, this.getCivTechnologyModifiers(civ));
this._civModifierCache.set(civ, { version: this._modifiersVersion, value: totals });
return totals;
},
// ------------------------------------------- research and culture --
getResearch(civ) {
return this.research.get(civ) || 0;
// Invalidates the memoised modifier totals after anything that shapes them
// changes: research, buildings or government.
_touchModifiers() {
this._modifiersVersion += 1;
},
// Construction and training take this much longer than the base time; a
// construction technology level of 1% shortens it by 1%.
constructionSpeedMultiplier(civ) {
const bonus = this.getCivModifiers(civ)[EFFECT_CONSTRUCTION_SPEED] || 0;
return 1 / (1 + Math.max(-0.99, bonus));
},
// The time multiplier for travelling a railway: a trains technology level of
// 1% moves 1% faster along rail.
railSpeedMultiplier(civ) {
const bonus = this.getCivModifiers(civ)[EFFECT_RAIL_SPEED] || 0;
return 1 / (1 + Math.max(-0.99, bonus));
},
// ------------------------------------------- research and culture --
getCulture(civ) {
return this.culture.get(civ) || 0;
},
@@ -508,23 +544,128 @@ export const economyMethods = {
canResearchTechnology(civ, index) {
if (civ < 0 || civ >= this.civilisations.length) return false;
if (index < 0 || index >= this.technologies.length) return false;
if (this.isResearched(civ, index)) return false;
return prerequisitesMet(
this.technologies[index],
this.researched.get(civ) || new Set(),
this.technologies
);
const tech = this.technologies[index];
// A repeatable technology may be researched again; any other is done once.
if (!tech.repeatable && this.isResearched(civ, index)) return false;
return prerequisitesMet(tech, this.researched.get(civ) || new Set(), this.technologies);
},
// The technology a nation is currently focusing its research on, or -1.
getFocusedTechnology(civ) {
const index = this.focusTechnology.get(civ);
return index === undefined ? -1 : index;
},
// The research points already committed to a technology. With no index the
// current focus's progress is returned, which is what the panel usually wants.
getTechnologyProgress(civ, index = this.getFocusedTechnology(civ)) {
if (index < 0) return 0;
const map = this.techProgress.get(civ);
return map ? map.get(index) || 0 : 0;
},
// How many times a repeatable technology has been researched.
getTechnologyRepeatCount(civ, index) {
const tech = this.technologies[index];
if (!tech) return 0;
const counts = this.repeatCounts.get(civ);
return counts ? counts.get(tech.id) || 0 : 0;
},
// The cost of the next level of a technology, growing 2% each repeat.
getTechnologyCost(civ, index) {
return technologyCost(this.technologies[index], this.getTechnologyRepeatCount(civ, index));
},
getCivTechnologyModifiers(civ) {
const totals = {};
const set = this.researched.get(civ) || new Set();
for (const index of set) {
mergeModifiers(totals, technologyEffects(this.technologies[index]));
const civResearch = this.researched.get(civ) || new Set();
for (const index of civResearch) {
const tech = this.technologies[index];
if (!tech || tech.repeatable) continue;
mergeModifiers(totals, technologyEffects(tech));
}
// Repeatable technologies stack their effect once per researched level.
const counts = this.repeatCounts.get(civ);
if (counts) {
for (const [id, count] of counts) {
const tech = technologyById(id);
if (!tech || count <= 0) continue;
for (const e of tech.effects) {
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, count);
}
}
}
return totals;
},
// -------------------------------------------------------- research --
// Points a nation creates flow straight into its focused technology; there is
// no bank, so time spent with nothing focused is simply time not advancing.
_addResearch(civ, amount) {
if (!(amount > 0)) return;
const index = this.getFocusedTechnology(civ);
if (index < 0) return;
this._applyTechnologyProgress(civ, index, this.getTechnologyProgress(civ, index) + amount);
},
// Records the points committed to one technology.
_setTechnologyProgress(civ, index, progress) {
let map = this.techProgress.get(civ);
if (!map) {
map = new Map();
this.techProgress.set(civ, map);
}
if (progress > 0) map.set(index, progress);
else map.delete(index);
},
// Commits `progress` points to a technology. Any overflow past its cost is
// discarded: nothing is carried over to whatever is focused next.
_applyTechnologyProgress(civ, index, progress) {
const cost = this.getTechnologyCost(civ, index);
if (progress < cost) {
this._setTechnologyProgress(civ, index, progress);
return;
}
this._discoverTechnology(civ, index);
// The completed level spends its points; a repeatable starts the next level
// from zero. Other technologies keep whatever they had banked.
this._setTechnologyProgress(civ, index, 0);
this._clearFocus(civ);
this._gdpPerCapitaCache.clear();
this._emitChanged();
},
// Clears the focus only: progress already committed to any technology stays
// put, so switching focus and coming back resumes where the nation left off.
_clearFocus(civ) {
this.focusTechnology.set(civ, -1);
},
// Marks a technology known. A repeatable one instead counts one more level;
// its notice is flagged so the news list can stay quiet while the feed still
// tells the player.
_discoverTechnology(civ, index) {
const tech = this.technologies[index];
if (!tech) return;
if (tech.repeatable) {
const counts = this.repeatCounts.get(civ);
if (counts) counts.set(tech.id, (counts.get(tech.id) || 0) + 1);
} else {
this.researched.get(civ).add(index);
}
this._touchModifiers();
this._addNews({
type: NEWS_TECHNOLOGY,
civ,
technology: tech.id,
name: tech.name,
repeatable: !!tech.repeatable,
});
},
getBudget(civ) {
return this.budgets.has(civ) ? this.budgets.get(civ) : 0;
},
@@ -568,7 +709,7 @@ export const economyMethods = {
const scienceRate = modifiers[EFFECT_RESEARCH] || 0;
const cultureRate = modifiers[EFFECT_CULTURE] || 0;
if (scienceRate === 0 && cultureRate === 0) continue;
this.research.set(civ, this.getResearch(civ) + scienceRate);
this._addResearch(civ, scienceRate);
this.culture.set(civ, this.getCulture(civ) + cultureRate);
}
},
@@ -628,6 +769,7 @@ export const economyMethods = {
const level = this.getCityBuildingLevel(city, entry.protoIndex);
city.buildings[entry.protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
},
// Units resting inside one of their own cities recover a little health each
+49
View File
@@ -0,0 +1,49 @@
// Month-over-month tracking for the nation summary bar. Once a calendar month
// rolls over, the nation figures captured at the start of the month that just
// ended become the baseline the current month is compared against, so every
// indicator can show whether it rose or fell since "the past month". January
// 2000 has no baseline, so it carries no change indicator.
import { unixTime } from "../game_clock.js";
export const monthlyMethods = {
// The absolute month number of an in-game hour, so a rollover is a simple
// comparison even across a year boundary.
_monthIndexOf(totalHours) {
const date = new Date(unixTime(totalHours) * 1000);
return date.getUTCFullYear() * 12 + date.getUTCMonth();
},
// Captures the starting month once the world exists.
_initMonthly() {
this._monthIndexNow = this._monthIndexOf(this.totalHours);
this._currentMonthStats = this._captureMonthlyStats();
this.monthlyStats = null;
},
// Called every hour; only a calendar-month change does any work.
_tickMonthly() {
const index = this._monthIndexOf(this.totalHours);
if (index === this._monthIndexNow) return;
// What was current at the start of the month that just ended is now the
// baseline for the new month.
this.monthlyStats = this._currentMonthStats;
this._currentMonthStats = this._captureMonthlyStats();
this._monthIndexNow = index;
},
// One record per civilisation of the indicators the summary bar compares.
_captureMonthlyStats() {
return this.civilisations.map((_, civ) => {
const headline = this._civHeadline(civ);
return {
population: headline.population,
gdp: headline.gdp,
gdpPerCapita: headline.gdpPerCapita,
popularity: this.getCivPopularity(civ),
approval: this.getCivApproval(civ),
budget: this.getBudget(civ),
};
});
},
};
+47 -11
View File
@@ -316,16 +316,20 @@ export const movementMethods = {
return 1 / (unit.moveSpeed || this._unitSpeed(unit));
}
// A road or railway levels the terrain: every such tile costs the same to
// cross, a railway half again as cheap as a road.
return this._tileMovementMultiplier(coords) / this._effectiveSpeedAt(unit, coords);
// cross, a railway half again as cheap as a road, and a nation's trains
// technology shortens its own railways further.
return this._tileMovementMultiplier(coords, unit.civ) / this._effectiveSpeedAt(unit, coords);
},
// The cheapest transport multiplier a tile offers: its railway, else its road,
// else the terrain's own. Flat across the whole tile, so a road levels hills
// and forests alike.
_tileMovementMultiplier(coords) {
// and forests alike. A nation's trains technology speeds up its railways only.
_tileMovementMultiplier(coords, civ = -1) {
const k = key(coords.x, coords.y);
if (this.railways.has(k)) return TRANSPORT_BY_ID.railway.movementCostMultiplier;
if (this.railways.has(k)) {
const base = TRANSPORT_BY_ID.railway.movementCostMultiplier;
return civ >= 0 ? base * this.railSpeedMultiplier(civ) : base;
}
if (this.roads.has(k)) return TRANSPORT_BY_ID.road.movementCostMultiplier;
return this.terrainStats.movementCostMultiplier(coords);
},
@@ -333,11 +337,14 @@ export const movementMethods = {
_fastestTileTravelHours(unit) {
// A railway (or, failing that, a road) is the cheapest possible tile, so the
// heuristic must use it when it beats the most generous terrain or A* could
// overestimate and miss the cheapest route.
// overestimate and miss the cheapest route. A railway can be cheaper still
// for a nation with trains technology.
const railway = TRANSPORT_BY_ID.railway.movementCostMultiplier *
this.railSpeedMultiplier(unit.civ);
const fastest = Math.min(
this._minMovementCost,
TRANSPORT_BY_ID.road.movementCostMultiplier,
TRANSPORT_BY_ID.railway.movementCostMultiplier
railway
);
return fastest / (this._unitSpeed(unit) * CONTROLLED_SPEED_MULTIPLIER);
},
@@ -377,7 +384,7 @@ export const movementMethods = {
// Without it every candidate tile scans the whole army; the search therefore
// looks up occupancy in constant time, exactly as the browser's prediction
// does in map_view/motion.js.
_canUnitEnter(unit, coords, isGoal = false, targetCiv = -1, occupancy = null) {
_canUnitEnter(unit, coords, isGoal = false, targetCiv = -1, occupancy = null, zoc = null, from = null) {
const proto = this.unitProto(unit);
const air = this._isAirProto(proto);
if (air) {
@@ -388,6 +395,14 @@ export const movementMethods = {
} else if (!this.canEnter(unit.civ, proto, coords)) {
return false;
}
// Zone of control: a hostile unit denies its own tile and the six around it.
// Troops may enter or leave the ring, but never cross straight from one
// denied tile into another. A deliberate attack on the goal is exempt, so
// the ring never shields a garrison from a charge.
if (!air && !isGoal && zoc && from &&
zoc.has(key(from.x, from.y)) && zoc.has(key(coords.x, coords.y))) {
return false;
}
const owner = this.civAt(coords);
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
const occupants = occupancy
@@ -429,6 +444,22 @@ export const movementMethods = {
return index;
},
// The tiles denied to `civ` by hostile ground forces: every hostile military
// unit's tile and the six around it. Aircraft project no zone.
_zoneOfControl(civ) {
const denied = new Set();
for (const other of this.units) {
if (other.civ === civ) continue;
if (!this._isMilitary(other) || this._isAirUnit(other)) continue;
const k = key(other.coords.x, other.coords.y);
denied.add(k);
for (const neighbour of this._neighbours(other.coords)) {
denied.add(key(neighbour.x, neighbour.y));
}
}
return denied;
},
// `pathCache` (optional) shares routes between units whose civ, type, start
// and goal are identical: the route shape depends only on the unit type's
// traversable terrain and the owning civ's territory/fog, while movement
@@ -441,6 +472,7 @@ export const movementMethods = {
if (cacheKey && pathCache.has(cacheKey)) return pathCache.get(cacheKey);
const occupancy = this._buildOccupancy(unit);
const targetCiv = this._targetCivFor(unit, goal, occupancy);
const zoc = this._zoneOfControl(unit.civ);
const enterCache = new Map();
const costCache = new Map();
const heuristicCache = new Map();
@@ -448,10 +480,14 @@ export const movementMethods = {
start,
goal,
(coords) => this._neighbours(coords),
(coords, isGoal) => {
const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
(coords, isGoal, from) => {
const fromKey = from ? key(from.x, from.y) : "";
const k = `${key(coords.x, coords.y)}${isGoal ? ":goal" : ""}@${fromKey}`;
if (!enterCache.has(k)) {
enterCache.set(k, this._canUnitEnter(unit, coords, isGoal, targetCiv, occupancy));
enterCache.set(
k,
this._canUnitEnter(unit, coords, isGoal, targetCiv, occupancy, zoc, from)
);
}
return enterCache.get(k);
},
+38 -8
View File
@@ -45,7 +45,9 @@ export const orderMethods = {
protoIndex,
capacity,
elapsedHours: 0,
totalHours: (proto.cost / capacity) * this.constructionApprovalMultiplier(city),
totalHours: (proto.cost / capacity) *
this.constructionApprovalMultiplier(city) *
this.constructionSpeedMultiplier(city.civ),
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
@@ -84,6 +86,7 @@ export const orderMethods = {
this._pendingBuildingLevels(cityId, protoIndex);
city.buildings[protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._emitChanged();
return true;
}
@@ -97,6 +100,7 @@ export const orderMethods = {
if (cost <= 0) {
city.buildings[protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._emitChanged();
return true;
}
@@ -109,7 +113,9 @@ export const orderMethods = {
level,
capacity,
elapsedHours: 0,
totalHours: (cost / capacity) * this.constructionApprovalMultiplier(city),
totalHours: (cost / capacity) *
this.constructionApprovalMultiplier(city) *
this.constructionSpeedMultiplier(city.civ),
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
@@ -143,6 +149,26 @@ export const orderMethods = {
return true;
},
// Removes whatever transport improvement stands on a land tile the
// civilisation controls. Removal is immediate and free, so a mis-built road
// can be cleared without a new one replacing it.
requestRemoveImprovement(civ, coords) {
if (civ < 0 || civ >= this.civilisations.length) return false;
if (!coords) return false;
if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false;
const k = key(coords.x, coords.y);
const tile = this.tiles[k];
if (!tile || tile.terrainClass !== "Land") return false;
if (this.civAt(coords) !== civ) return false;
const present = this.railways.has(k) || this.roads.has(k);
if (!present) return false;
this.roads.delete(k);
this.railways.delete(k);
this._improvementVersion += 1;
this._emitChanged();
return true;
},
requestDemolish(cityId, protoIndex) {
const city = this.findCity(cityId);
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
@@ -155,6 +181,7 @@ export const orderMethods = {
if (level <= 1) delete city.buildings[protoIndex];
else city.buildings[protoIndex] = level - 1;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._emitChanged();
return true;
},
@@ -169,17 +196,20 @@ export const orderMethods = {
if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost);
this.government.set(civ, index);
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._emitChanged();
return true;
},
requestResearchTechnology(civ, index) {
// Focuses a nation's research on one technology. Every point it creates from
// now on flows straight into it; nothing is charged up front and nothing is
// banked while no technology is focused.
requestFocusTechnology(civ, index) {
if (!this.canResearchTechnology(civ, index)) return false;
const technology = this.technologies[index];
if (this.getResearch(civ) < technology.cost) return false;
this.research.set(civ, this.getResearch(civ) - technology.cost);
this.researched.get(civ).add(index);
this._gdpPerCapitaCache.clear();
if (this.getFocusedTechnology(civ) === index) return true;
// Progress is kept per technology, so switching focus never discards work
// already done on either the old focus or the new one.
this.focusTechnology.set(civ, index);
this._emitChanged();
return true;
},
+108 -20
View File
@@ -27,9 +27,11 @@ import {
REBELLION,
WAR_DECLARATION,
CULTURE_IMPACT,
POPULARITY,
} from "../data/politics.js";
import { NEWS_POLICY } from "../data/relations.js";
import { EFFECT_CULTURE } from "../data/effects.js";
import { popularityDisplay, popularityPoints } from "../rules.js";
import { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js";
// How far from one of its cities an ethnicity can meaningfully spread. Beyond
@@ -57,7 +59,6 @@ export const politicsMethods = {
_tickPolitics() {
this._tickCampaigns();
this._tickOpinionDrift();
this._tickMigration();
this._tickUprisings();
this._tickCultureImpact();
@@ -204,6 +205,19 @@ export const politicsMethods = {
return this._makeupList(counts, this._migrationDeltas.civ.get(civ), scale);
},
// One ethnicity's share of a civilisation's whole population, 0 when it has
// no presence there. Used to bound expulsions.
getCivEthnicShare(civ, ethnicity) {
const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []);
let total = 0;
let target = 0;
for (const [eth, count] of counts) {
total += count;
if (eth === ethnicity) target += count;
}
return total > 0 ? target / total : 0;
},
// The ethnic makeup of one city's region.
getCityEthnicMakeup(city) {
const scale = this._populationBalance.get(city.civ) || 1;
@@ -295,8 +309,10 @@ export const politicsMethods = {
}
},
// Opinions slide one point an hour back toward neutral, so the rate is a
// constant 1 point/h and the time to neutrality is simply the points left.
_tickOpinionDrift() {
const step = OPINION.driftPerDay;
const step = POPULARITY.driftPerHour * POPULARITY.opinionPerPoint;
this._driftStore(this.ethnicOpinions, step);
this._driftStore(this.govOpinions, step);
},
@@ -316,42 +332,74 @@ export const politicsMethods = {
// ------------------------------------------------------ approval --
// A city region's approval of its government: the population-weighted mean of
// every resident population's opinion of it.
getCityApproval(city) {
// A city region's opinion of its government, in one pass: the linear
// population-weighted approval (which drives tax and construction) and the
// same figure in integer popularity points (what the player is shown).
getCityOpinions(city) {
const version = this._politicsVersion;
const cached = this._approvalCache.get(city.id);
if (cached && cached.version === version) return cached.value;
const value = this._weightedApproval(this.regionTiles(city), city.civ);
this._approvalCache.set(city.id, { version, value });
return value;
if (cached && cached.version === version) return cached;
const value = this._weightedOpinions(this.regionTiles(city), city.civ);
const record = { version, ...value };
this._approvalCache.set(city.id, record);
return record;
},
// A nation's government approval among its own people. Memoised against the
// politics version, like getCityApproval, since the snapshot reads it for
// A nation's opinion figures among its own people. Memoised against the
// politics version, like getCityOpinions, since the snapshot reads it for
// every nation on every broadcast.
getCivApproval(civ) {
getCivOpinions(civ) {
const version = this._politicsVersion;
const cached = this._civApprovalCache.get(civ);
if (cached && cached.version === version) return cached.value;
const value = this._weightedApproval(this._territoryByCiv.get(civ) || [], civ);
this._civApprovalCache.set(civ, { version, value });
return value;
if (cached && cached.version === version) return cached;
const value = this._weightedOpinions(this._territoryByCiv.get(civ) || [], civ);
const record = { version, ...value };
this._civApprovalCache.set(civ, record);
return record;
},
_weightedApproval(coordsList, government) {
getCityApproval(city) {
return this.getCityOpinions(city).approval;
},
getCivApproval(civ) {
return this.getCivOpinions(civ).approval;
},
getCityPopularity(city) {
return this.getCityOpinions(city).popularity;
},
getCivPopularity(civ) {
return this.getCivOpinions(civ).popularity;
},
// What the player sees, in 0..1: the popularity points through the logistic.
getCityPopularityDisplay(city) {
return popularityDisplay(this.getCityPopularity(city));
},
getCivPopularityDisplay(civ) {
return popularityDisplay(this.getCivPopularity(civ));
},
_weightedOpinions(coordsList, government) {
let total = 0;
let points = 0;
let weight = 0;
for (const coords of coordsList) {
const k = key(coords.x, coords.y);
const population = this.tilePopulation.get(k) || 0;
for (const [eth, share] of this.getTileEthnicFractions(coords)) {
const w = population * share;
total += w * this.getGovOpinion(eth, government);
const opinion = this.getGovOpinion(eth, government);
total += w * opinion;
points += w * popularityPoints(opinion);
weight += w;
}
}
return weight > 0 ? total / weight : OPINION.baseline;
if (weight <= 0) return { approval: OPINION.baseline, popularity: 0 };
return { approval: total / weight, popularity: Math.round(points / weight) };
},
// The multiplier on a city's construction time. Above the approval floor it
@@ -533,6 +581,11 @@ export const politicsMethods = {
if (!record) return false;
if (!isKnownPolicy(policyId)) return false;
if (this.hasPolicy(civ, policyId, ethnicity)) return false;
// A minority that makes up more than a tenth of the country cannot simply
// be expelled: its people are too many to drive out.
if (policyId === POLICY_EXPEL && this.getCivEthnicShare(civ, ethnicity) > 0.1) {
return false;
}
record.push({ id: this._nextPolicyId++, type: policyId, ethnicity });
this._applyPolicyOpinion(civ, policyId, ethnicity);
this._addNews({ type: NEWS_POLICY, civ, policy: policyId, ethnicity });
@@ -771,19 +824,54 @@ export const politicsMethods = {
this._addGrudge(observer, aggressor, -WAR_CRIME.tileOpinion * factor, until);
this._shiftEthnicOpinion(observer, aggressor, -WAR_CRIME.tileEthnicOpinion * factor);
}
// The world's governments also simply lose standing over the atrocity.
this._applyPillagePopularity(aggressor, coords);
this._touchPolitics();
},
// ------------------------------------------------ war and uprising --
// The world reacts to a declaration of war: onlookers who liked the victim
// turn on the aggressor, while those who loathed it quietly approve.
// turn on the aggressor, while those who loathed it quietly approve. On top
// of that the aggressor's own people resent the war, and a people rallied by
// an unprovoked attack on a government it already loathes take heart.
_applyWarPopularity(actor, target) {
for (let observer = 0; observer < this.civilisations.length; observer++) {
const stance = this.getGovOpinion(observer, target);
const delta = -WAR_DECLARATION.penalty * (stance - OPINION.baseline) * 2;
if (delta !== 0) this._shiftGovOpinion(observer, actor, delta);
}
this._shiftOwnPopularity(actor, -POPULARITY.declareWar);
// A people already hostile to the aggressor rallies behind its own
// government when that aggressor attacks.
const aggressorStanding =
this._weightedOpinions(this._territoryByCiv.get(target) || [], actor).approval;
if (aggressorStanding < POPULARITY.declaredOnThreshold) {
this._shiftOwnPopularity(target, POPULARITY.declaredOnBoost);
}
this._touchPolitics();
},
// Move a government's standing with its own people by `points`.
_shiftOwnPopularity(civ, points) {
if (!this._validCiv(civ) || points === 0) return;
const delta = points * POPULARITY.opinionPerPoint;
const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []);
for (const eth of counts.keys()) this._shiftGovOpinion(eth, civ, delta);
},
// The world turns against an aggressor that pillages a tile, with the peoples
// directly affected feeling it twice as sharply. It is a grudge, so it fades
// on the same clock as the rest of the war-crime memory.
_applyPillagePopularity(aggressor, coords) {
if (!this._validCiv(aggressor)) return;
const until = this.totalHours + WAR_CRIME.memoryYears * HOURS_PER_YEAR;
const amount = -POPULARITY.pillage * POPULARITY.opinionPerPoint;
for (let observer = 0; observer < this.civilisations.length; observer++) {
this._addGrudge(observer, aggressor, amount, until);
}
// The tile's dominant people carry the double debuff.
this._addGrudge(this._dominantEthnicity(coords), aggressor, amount, until);
this._touchPolitics();
},
+113 -5
View File
@@ -3,6 +3,7 @@
import { parseKey } from "../hex.js";
import { OPINION } from "../data/politics.js";
import { EFFECT_RAIL_SPEED, EFFECT_CONSTRUCTION_SPEED, EFFECT_RESEARCH } from "../data/effects.js";
import { serializeSet } from "./helpers.js";
export const serializationMethods = {
@@ -34,6 +35,10 @@ export const serializationMethods = {
// improvement version.
roads: this._serializeTransport(this.roads, "_serializedRoadsCache"),
railways: this._serializeTransport(this.railways, "_serializedRailwaysCache"),
// War-time tile works: trenches as [x, y], military improvements as
// [x, y, id, owner, hp].
trenches: this._serializeTrenches(),
tileImprovements: this._serializeTileImprovements(),
// Each land tile's region: [x, y, cityId], so the browser attributes a
// tile's GDP modifiers to the city that develops it.
regions: this._serializeRegions(),
@@ -45,7 +50,6 @@ export const serializationMethods = {
civStats: this._serializeCivStats(),
cityStats: this._serializeCityStats(),
budgets: this._serializeCivValues(this.budgets),
research: this._serializeCivValues(this.research),
culture: this._serializeCivValues(this.culture),
// Each nation's share of the world's cultural output (zero-sum) and the
// people's approval of every government.
@@ -66,16 +70,28 @@ export const serializationMethods = {
},
government: this._serializeCivValues(this.government),
researched: this._serializeResearched(),
// Focused research: the technology each nation is working on, the points
// committed to every technology it has started, and how many levels of
// each repeatable it has.
focusTechnology: this._serializeCivValues(this.focusTechnology),
techProgress: this._serializeTechProgress(),
repeatCounts: this._serializeRepeatCounts(),
diplomacy: this._serializeDiplomacy(),
conflicts: this._serializeConflicts(),
news: this._serializeNews(),
battles: this._serializeBattles(),
// Each nation's indicators a month ago, for the summary change arrows;
// empty during January 2000, which has no past month.
monthly: this.monthlyStats || [],
training: Object.fromEntries(
Array.from(this.training.entries()).map(([cityId, queue]) => [
cityId,
queue.map((entry) => ({ ...entry })),
])
),
// The military improvements each nation has under construction, so the
// tile panel can show a progress bar like a city's production.
tileWorks: this._serializeTileWorks(),
// Which of the large per-tile collections changed since the last
// broadcast, so a viewer can rebuild only those and the server can omit
// the rest from the wire (see GameServer._broadcastState).
@@ -85,6 +101,7 @@ export const serializationMethods = {
population: this._populationVersion,
ethnicity: this._ethnicityVersion,
improvements: this._improvementVersion,
warfare: this._tileImprovementVersion,
},
};
},
@@ -102,20 +119,28 @@ export const serializationMethods = {
if (viewerCiv < 0) {
return {
population: 0, gdp: 0, gdpPerCapita: 0, budget: 0,
upkeep: 0, research: 0, culture: 0, government: 0,
approval: 0.5, cultureImpact: 0, ethnicMakeup: [],
upkeep: 0, researchRate: 0, culture: 0, government: 0,
approval: 0.5, popularity: 0, cultureImpact: 0, ethnicMakeup: [],
constructionSpeed: 0, railSpeed: 0,
breakdown: null,
};
}
const headline = this._civHeadline(viewerCiv);
const modifiers = this.getCivModifiers(viewerCiv);
return {
...headline,
// Repeatable research the browser mirrors: construction and rail speed.
constructionSpeed: modifiers[EFFECT_CONSTRUCTION_SPEED] || 0,
railSpeed: modifiers[EFFECT_RAIL_SPEED] || 0,
budget: this.getBudget(viewerCiv),
upkeep: this.getPlayerUpkeep(viewerCiv),
research: this.getResearch(viewerCiv),
// The research this nation creates each hour, all of which flows into the
// focused technology. There is no stock of points.
researchRate: modifiers[EFFECT_RESEARCH] || 0,
culture: this.getCulture(viewerCiv),
government: this.getGovernment(viewerCiv),
approval: this.getCivApproval(viewerCiv),
popularity: this.getCivPopularity(viewerCiv),
cultureImpact: this.getCulturalImpact(viewerCiv),
ethnicMakeup: this.getCivEthnicMakeup(viewerCiv),
breakdown: this.budgetBreakdown(viewerCiv),
@@ -177,6 +202,11 @@ export const serializationMethods = {
stepHours,
segmentHours,
waypoints: (unit.waypoints || []).map((coords) => [coords.x, coords.y]),
// A transport's cargo and any boarding still under way.
cargo: (unit.cargo || []).map((passenger) => ({ ...passenger })),
embarking: unit.embarking
? { hoursLeft: unit.embarking.hoursLeft, count: unit.embarking.ids.length }
: null,
};
});
},
@@ -197,6 +227,30 @@ export const serializationMethods = {
return result;
},
_serializeTrenches() {
const result = [];
for (const k of this.trenches) {
const coords = parseKey(k);
result.push([coords.x, coords.y]);
}
return result;
},
_serializeTileImprovements() {
const result = [];
for (const [k, id] of this.tileImprovements) {
const coords = parseKey(k);
result.push([
coords.x,
coords.y,
id,
this.tileImprovementOwner.get(k) ?? -1,
this.tileImprovementHp.get(k) || 0,
]);
}
return result;
},
// A sparse [x, y, cityId] list of every tile's region, memoised against the
// region version so an unchanged world is not re-walked on every broadcast.
_serializeRegions() {
@@ -243,6 +297,7 @@ export const serializationMethods = {
return this.civilisations.map((_, index) => ({
...this._civHeadline(index),
approval: this.getCivApproval(index),
popularity: this.getCivPopularity(index),
cultureImpact: this.getCulturalImpact(index),
}));
},
@@ -288,6 +343,42 @@ export const serializationMethods = {
return result;
},
// The points each nation has committed to every technology it has started,
// as { technologyIndex: points } objects. Work is never lost when the focus
// changes, so every started technology travels in the snapshot.
_serializeTechProgress() {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
const map = this.techProgress.get(i);
const record = {};
if (map) {
for (const [index, points] of map) {
if (points > 0) record[index] = points;
}
}
result.push(record);
}
return result;
},
// The tile improvements under construction, one array per nation, each entry
// { coords: [x, y], id, elapsedHours, totalHours }.
_serializeTileWorks() {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
const queue = this.tileWorks.get(i) || [];
result.push(
queue.map((work) => ({
coords: [work.coords.x, work.coords.y],
id: work.id,
elapsedHours: work.elapsedHours,
totalHours: work.totalHours,
}))
);
}
return result;
},
_serializeResearched() {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
@@ -298,6 +389,23 @@ export const serializationMethods = {
return result;
},
// How many times each nation has researched every repeatable technology, as
// { technologyId: count } objects, skipping the unused ones.
_serializeRepeatCounts() {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
const counts = this.repeatCounts.get(i);
const record = {};
if (counts) {
for (const [id, count] of counts) {
if (count > 0) record[id] = count;
}
}
result.push(record);
}
return result;
},
// Every unordered pair with its current status. With a handful of
// civilisations the whole matrix is tiny, and it lets the client show the
// status of every nation without a second lookup.
@@ -392,7 +500,7 @@ export const serializationMethods = {
for (let a = 0; a < this.civilisations.length; a++) {
const row = [];
for (let b = 0; b < this.civilisations.length; b++) {
row.push(roundTo(this._weightedApproval(this._territoryByCiv.get(a) || [], b), 3));
row.push(roundTo(this._weightedOpinions(this._territoryByCiv.get(a) || [], b).approval, 3));
}
result.push(row);
}
+30
View File
@@ -3,6 +3,8 @@
import { key, parseKey } from "../hex.js";
import { NEWS_CAPTURE } from "../data/relations.js";
import { POPULARITY } from "../data/politics.js";
import { tileImprovementById } from "../data/improvements.js";
import { serializeCells } from "./helpers.js";
export const territoryMethods = {
@@ -60,6 +62,22 @@ export const territoryMethods = {
this._indexTerritory();
},
// The tiles a nation's fortifications anchor: a fort's own tile and its
// neighbours. Even if an enemy takes the nearby city, these tiles stay.
_fortifiedControl(civ) {
const anchored = new Set();
for (const [k, id] of this.tileImprovements) {
const proto = tileImprovementById(id);
if (!proto || !proto.permanentControl) continue;
if (this.tileImprovementOwner.get(k) !== civ) continue;
anchored.add(k);
for (const neighbour of this._neighbours(parseKey(k))) {
anchored.add(key(neighbour.x, neighbour.y));
}
}
return anchored;
},
// Subdivides each nation's territory into the regions of its own cities: a
// multi-source BFS grown from every city, but never crossing into another
// nation's land. Ties between equally close cities are settled by city order,
@@ -140,9 +158,13 @@ export const territoryMethods = {
if (oldCiv === newCiv) return false;
const region = (this._territoryByCiv.get(oldCiv) || []).slice();
const claim = this._nearestCityOwner(city, oldCiv, newCiv);
// Fortifications hold a permanent area of control: their tile and the ring
// around it stay with the builder even when the city nearby changes hands.
const anchored = this._fortifiedControl(oldCiv);
city.civ = newCiv;
for (const coords of region) {
const k = key(coords.x, coords.y);
if (anchored.has(k)) continue;
if (claim.get(k) === newCiv) this.territory.set(k, newCiv);
}
// The captured city's own tile always changes hands, ties or not.
@@ -151,6 +173,14 @@ export const territoryMethods = {
this._reindexTerritory();
this._rebuildCityIndex();
this._visibilityDirty = true;
// Aircraft caught on the captured airfield change allegiance with it.
this._captureCityAircraft(city, newCiv);
// The city's buildings now serve a different nation, so its modifier
// totals changed hands.
this._touchModifiers();
// Taking a city heartens the conqueror's people and demoralises the losers.
this._shiftOwnPopularity(newCiv, POPULARITY.cityCapture);
this._shiftOwnPopularity(oldCiv, -POPULARITY.cityLoss);
this._addNews({ type: NEWS_CAPTURE, city: city.name, from: oldCiv, to: newCiv });
this._emitChanged();
return true;
+12 -2
View File
@@ -1,8 +1,9 @@
// Fog of war and the grid walkers. Explored cells stay known; visible cells
// are the ones currently watched by territory and units.
import { key } from "../hex.js";
import { key, parseKey } from "../hex.js";
import { UNIT_VISION, TERRITORY_VISION } from "./constants.js";
import { tileImprovementById } from "../data/improvements.js";
import { serializeSet } from "./helpers.js";
export const visibilityMethods = {
@@ -30,7 +31,16 @@ export const visibilityMethods = {
for (const coords of cells) this._reveal(civ, coords, TERRITORY_VISION);
}
for (const unit of this.units) {
this._reveal(unit.civ, unit.coords, UNIT_VISION);
const proto = this.unitProto(unit);
this._reveal(unit.civ, unit.coords, (proto && proto.vision) || UNIT_VISION);
}
// Fortifications and radar towers watch their own neighbourhood too.
for (const [k, id] of this.tileImprovements) {
const proto = tileImprovementById(id);
if (!proto || !proto.vision) continue;
const owner = this.tileImprovementOwner.get(k);
if (owner === undefined || owner < 0) continue;
this._reveal(owner, parseKey(k), Math.max(UNIT_VISION, proto.vision));
}
},
+362
View File
@@ -0,0 +1,362 @@
// War-time tile works: trenches dug by infantry, the military improvements a
// nation raises on its land (fortifications, radar towers, coastal cannons),
// and the damage, capture and upkeep that come with them.
import { key, parseKey } from "../hex.js";
import { ECONOMY } from "../data/economy.js";
import { TRANSPORT_BY_ID, tileImprovementById } from "../data/improvements.js";
import { TRENCH } from "../data/combat.js";
import { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
export const warfareMethods = {
// ----------------------------------------------------------- trenches --
// Infantry dig in on a land tile their nation controls. A trench shelters
// the garrison from melee and artillery at the cost of local GDP.
requestDigTrench(unitIds) {
const units = this._unitsForOrder(unitIds);
if (units.length === 0) return false;
const coords = units[0].coords;
if (!units.every((unit) => unit.coords.x === coords.x && unit.coords.y === coords.y)) {
return false;
}
const civ = units[0].civ;
if (!units.every((unit) => unit.civ === civ)) return false;
// Only infantry can dig, and only into their own land.
if (!units.every((unit) => {
const proto = this.unitProto(unit);
return proto && proto.infantry;
})) return false;
if (this.civAt(coords) !== civ) return false;
const tile = this.tiles[key(coords.x, coords.y)];
if (!tile || tile.terrainClass !== "Land") return false;
const k = key(coords.x, coords.y);
if (this.trenches.has(k)) return false;
this.trenches.add(k);
this._gdpPerCapitaCache.delete(k);
this._emitChanged();
return true;
},
// ------------------------------------------------- tile improvements --
// Queues a military tile improvement on a land tile the nation controls. The
// work takes the same GDP-scaled time as any other construction and completes
// on the hourly tile-works tick.
requestBuildTileImprovement(civ, coords, id) {
if (!this._validCiv(civ) || !coords) return false;
if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false;
const proto = tileImprovementById(id);
if (!proto) return false;
const k = key(coords.x, coords.y);
const tile = this.tiles[k];
if (!tile || tile.terrainClass !== "Land") return false;
if (this.civAt(coords) !== civ) return false;
if (this.tileImprovements.has(k)) return false;
if (proto.coastal && !this._isCoastalTile(coords)) return false;
const gdp = this.getPlayerGdp(civ);
if (ECONOMY.productionCapacity(gdp) <= 0) return false;
if (!this._spendBudget(civ, proto.buildCost)) return false;
const work = {
coords: { x: coords.x, y: coords.y },
id,
elapsedHours: 0,
totalHours: ECONOMY.trainingHours(gdp, proto.buildCost) * this.constructionSpeedMultiplier(civ),
};
const queue = this.tileWorks.get(civ);
if (queue) queue.push(work);
else this.tileWorks.set(civ, [work]);
this._emitChanged();
return true;
},
// Tears down a military improvement (or a trench) on a controlled tile.
requestRemoveTileImprovement(civ, coords) {
if (!this._validCiv(civ) || !coords) return false;
const k = key(coords.x, coords.y);
if (this.civAt(coords) !== civ) return false;
let removed = false;
if (this.trenches.delete(k)) removed = true;
if (this.tileImprovements.delete(k)) {
this.tileImprovementOwner.delete(k);
this.tileImprovementHp.delete(k);
removed = true;
}
if (!removed) return false;
this._tileImprovementVersion += 1;
this._gdpPerCapitaCache.delete(k);
this._emitChanged();
return true;
},
// Advances every nation's queued tile works by an hour, completing the head
// of each queue and carrying the overflow into the next.
_tickTileWorks() {
for (const [civ, queue] of this.tileWorks) {
let hoursLeft = 1;
while (hoursLeft > 0 && queue.length > 0) {
const work = queue[0];
const remaining = Math.max(work.totalHours - work.elapsedHours, 0);
if (hoursLeft >= remaining) {
hoursLeft -= remaining;
this._completeTileWork(civ, work);
queue.shift();
} else {
work.elapsedHours += hoursLeft;
hoursLeft = 0;
}
}
if (queue.length === 0) this.tileWorks.delete(civ);
}
},
_completeTileWork(civ, work) {
const k = key(work.coords.x, work.coords.y);
// The tile may have been taken or already improved while the work ran.
if (this.civAt(work.coords) !== civ || this.tileImprovements.has(k)) return;
const proto = tileImprovementById(work.id);
if (!proto) return;
this.tileImprovements.set(k, work.id);
this.tileImprovementOwner.set(k, civ);
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000);
this._tileImprovementVersion += 1;
this._gdpPerCapitaCache.delete(k);
this._visibilityDirty = true;
this._emitChanged();
},
// Whether a land tile touches the sea, so a coastal cannon may be raised.
_isCoastalTile(coords) {
for (const neighbour of this._neighbours(coords)) {
const tile = this.tiles[key(neighbour.x, neighbour.y)];
if (tile && tile.terrainClass === "Sea") return true;
}
return false;
},
_tileImprovementAt(coords) {
const id = this.tileImprovements.get(key(coords.x, coords.y));
return id ? tileImprovementById(id) : null;
},
// The extra defense a tile's fortifications and trenches give a defender,
// against a melee or artillery attacker.
_tileFortificationMultiplier(coords, attackerProto) {
const k = key(coords.x, coords.y);
const artillery = this._isArtilleryAttack(attackerProto);
let multiplier = 1;
const id = this.tileImprovements.get(k);
if (id) {
const proto = tileImprovementById(id);
if (proto && proto.defense) {
multiplier *= 1 + (artillery ? proto.defense.artillery : proto.defense.melee);
}
}
if (this.trenches.has(k)) {
multiplier *= artillery
? TRENCH.artilleryDefenseMultiplier
: TRENCH.meleeDefenseMultiplier;
}
return multiplier;
},
// Whether an attacker's blow is artillery (or aircraft) fire rather than a
// melee charge, so trench and fortification bonuses can tell them apart.
_isArtilleryAttack(proto) {
if (!proto) return false;
if (proto.artillery || proto.air) return true;
return (proto.range || 1) > 1;
},
// Damage a tile improvement from ranged fire. Fortifications and coastal
// cannons are knocked down when their hit points run out; a radar tower is
// captured by infantry rather than destroyed.
_damageTileImprovement(coords, amount) {
const k = key(coords.x, coords.y);
const id = this.tileImprovements.get(k);
if (!id) return false;
const proto = tileImprovementById(id);
if (!proto || !(proto.takesRangedDamage || proto.takesDamage || proto.takesCombatDamage)) {
return false;
}
const hp = (this.tileImprovementHp.get(k) || 0) - amount;
if (hp <= 0) {
this.tileImprovements.delete(k);
this.tileImprovementOwner.delete(k);
this.tileImprovementHp.delete(k);
this._tileImprovementVersion += 1;
this._emitChanged();
return true;
}
this.tileImprovementHp.set(k, hp);
return true;
},
// An enemy walking onto a capturable improvement takes it for their nation.
_captureTileImprovement(coords, civ) {
const k = key(coords.x, coords.y);
const id = this.tileImprovements.get(k);
if (!id) return false;
const proto = tileImprovementById(id);
if (!proto || !proto.capturable) return false;
const owner = this.tileImprovementOwner.get(k);
if (owner === civ) return false;
this.tileImprovementOwner.set(k, civ);
this._tileImprovementVersion += 1;
this._emitChanged();
return true;
},
// The hourly maintenance of every military improvement a nation holds.
getTileImprovementUpkeep(civ) {
let total = 0;
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (proto) total += proto.maintenance;
}
return total;
},
_tileImprovementUpkeepSources(civ) {
const byType = new Map();
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (!proto) continue;
const entry = byType.get(id) || { label: proto.name, amount: 0, count: 0 };
entry.amount += proto.maintenance;
entry.count += 1;
byType.set(id, entry);
}
return Array.from(byType.values()).filter((entry) => entry.amount > 0);
},
// ------------------------------------------------------ transports --
// Infantry board a friendly launch. They must stand on the shore beside it;
// the boarding takes EMBARK_HOURS in-game hours to complete.
requestEmbark(infantryIds, launchId) {
const launch = this.findUnit(Number(launchId));
if (!launch) return false;
const proto = this.unitProto(launch);
if (!proto || !(proto.transportCapacity > 0)) return false;
if (launch.embarking) return false;
const cargo = launch.cargo || [];
const infantry = this._unitsForOrder(infantryIds).filter((unit) => {
const unitProto = this.unitProto(unit);
return unitProto && unitProto.infantry && unit.civ === launch.civ;
});
if (infantry.length === 0) return false;
if (cargo.length + infantry.length > proto.transportCapacity) return false;
// The troops must be on the shore next to the launch.
if (!infantry.every((unit) =>
this.topology.tileDistance(unit.coords, launch.coords) === 1)) {
return false;
}
launch.embarking = { ids: infantry.map((unit) => unit.id), hoursLeft: EMBARK_HOURS };
this._emitChanged();
return true;
},
// Finishes any boarding: at zero hours the infantry leave the map and become
// the launch's cargo, and are carried until put ashore.
_tickEmbark() {
for (const unit of this.units) {
if (!unit.embarking) continue;
unit.embarking.hoursLeft -= 1;
if (unit.embarking.hoursLeft > 0) continue;
const proto = this.unitProto(unit);
const capacity = proto ? proto.transportCapacity || 0 : 0;
if (!unit.cargo) unit.cargo = [];
for (const id of unit.embarking.ids) {
if (unit.cargo.length >= capacity) break;
const infantry = this.findUnit(id);
if (!infantry || infantry.civ !== unit.civ) continue;
unit.cargo.push({ proto: infantry.proto, hp: infantry.hp });
this._destroyUnit(infantry);
this._visibilityDirty = true;
}
unit.embarking = null;
this._emitChanged();
}
},
// Puts a whole stack of launches' cargo ashore on an adjacent coastal tile.
requestDisembark(launchIds, coords) {
if (!coords) return false;
const launches = this._unitsForOrder(launchIds).filter((unit) => {
const proto = this.unitProto(unit);
return proto && proto.transportCapacity > 0;
});
if (launches.length === 0) return false;
const civ = launches[0].civ;
if (!launches.every((unit) => unit.civ === civ)) return false;
const k = key(coords.x, coords.y);
const tile = this.tiles[k];
if (!tile || tile.terrainClass !== "Land") return false;
if (!launches.every((unit) => this.topology.tileDistance(unit.coords, coords) === 1)) {
return false;
}
let landed = false;
for (const launch of launches) {
const cargo = launch.cargo || [];
for (const passenger of cargo) {
const proto = this.protoUnits[passenger.proto];
if (!proto) continue;
const unit = this._spawnUnit(coords, civ, proto);
if (passenger.hp !== undefined) unit.hp = Math.min(proto.maxHp, passenger.hp);
landed = true;
}
launch.cargo = [];
}
if (landed) {
this._visibilityDirty = true;
this._emitChanged();
}
return landed;
},
// -------------------------------------------------- coastal cannons --
// Coastal cannons fire at hostile warships within range every hour.
_tickCoastalCannons() {
const damaged = new Map();
for (const [k, id] of this.tileImprovements) {
const proto = tileImprovementById(id);
if (!proto || !proto.automatic) continue;
const owner = this.tileImprovementOwner.get(k);
const coords = parseKey(k);
for (const target of this._unitsWithin(coords, proto.range || 1)) {
const targetProto = this.unitProto(target);
if (!targetProto || !targetProto.naval) continue;
if (target.civ === owner) continue;
const amount = COASTAL_CANNON.damage;
damaged.set(target, (damaged.get(target) || 0) + amount);
}
}
if (damaged.size === 0) return;
const destroyed = [];
for (const [unit, amount] of damaged) {
unit.hp -= amount;
if (unit.hp <= 0) {
unit.hp = 0;
destroyed.push(unit);
}
}
this._recordMilitaryCasualties(destroyed);
for (const unit of destroyed) this._destroyUnit(unit);
if (destroyed.length > 0) this._visibilityDirty = true;
this._emitChanged();
},
// Every unit of another nation within `radius` tiles of `coords`.
_unitsWithin(coords, radius) {
const result = [];
const origin = { x: coords.x, y: coords.y };
for (const unit of this.units) {
if (this.topology.tileDistance(origin, unit.coords) <= radius) result.push(unit);
}
return result;
},
};
+15 -8
View File
@@ -4,6 +4,7 @@
import { ECONOMY } from "../data/economy.js";
import { ROADS } from "../data/roads.js";
import { transportImprovement } from "../data/improvements.js";
import { buildRoadNetwork } from "../roads.js";
import { MapTopology, key, parseKey } from "../hex.js";
import { MapGenerator } from "../map_generator.js";
@@ -173,20 +174,23 @@ export const worldMethods = {
return candidate;
},
// Lays the pre-generated road network. Cities are founded with a road, so
// they are free nodes while the network is drawn; the chosen links are the
// greedy spanner over every possible city-to-city route.
// Lays the pre-generated road network. Cities are founded on a railway (they
// improve themselves), so they are free nodes while the network is drawn; the
// chosen links are the greedy spanner over every possible city-to-city route.
_generateRoads(roadKeys = null) {
for (const city of this.cities) {
if (!city.improvements.includes(ROADS.improvementName)) {
city.improvements.push(ROADS.improvementName);
}
}
const railwayName = transportImprovement("railway").name;
// A cached world hands back the (read-only) road tiles; copy them so no
// state can alter the cache.
this.roads = roadKeys
? new Set(roadKeys)
: buildRoadNetwork(this.cities, this.topology, this.tiles);
// A city always stands on a railway, so no player has to improve it.
for (const city of this.cities) {
if (!city.improvements.includes(railwayName)) city.improvements.push(railwayName);
const k = key(city.coords.x, city.coords.y);
this.roads.delete(k);
this.railways.add(k);
}
},
_spawnUnit(center, civIndex, proto, homeCityId = null) {
@@ -214,6 +218,9 @@ export const worldMethods = {
strikeTarget: null,
strikeReadyHour: 0,
missionFlown: false,
// A transport's cargo and any boarding under way.
cargo: [],
embarking: null,
};
if (proto.air) {
unit.homeCityId = homeCityId;
+6 -4
View File
@@ -2,14 +2,16 @@
// the graph with callbacks so the algorithm stays pure and testable:
//
// neighbours(coords) -> [{x,y}, ...]
// canEnter(coords, isGoal) -> bool
// canEnter(coords, isGoal, from) -> bool
// stepCost(from, to) -> number
// heuristic(from, to) -> number
//
// canEnter is told whether the tile is the destination, so a caller can allow
// entering a tile it would otherwise route around (a hostile unit or city) only
// as the very goal. findPath returns the cells from start to goal inclusive, or
// [] when the goal cannot be entered or is unreachable.
// as the very goal, and which tile the step came from, so a caller can forbid
// crossing from one denied tile straight into another. findPath returns the
// cells from start to goal inclusive, or [] when the goal cannot be entered or
// is unreachable.
import { key } from "./hex.js";
@@ -97,7 +99,7 @@ export class HexPathfinder {
for (const neighbour of neighbours(current)) {
const neighbourKey = key(neighbour.x, neighbour.y);
if (closed.has(neighbourKey)) continue;
if (!canEnter(neighbour, neighbourKey === goalKey)) continue;
if (!canEnter(neighbour, neighbourKey === goalKey, current)) continue;
const tentative = (gScore.has(currentKey) ? gScore.get(currentKey) : Infinity) +
stepCost(current, neighbour);
if (tentative < (gScore.has(neighbourKey) ? gScore.get(neighbourKey) : Infinity)) {
+23 -1
View File
@@ -1,7 +1,25 @@
// Catalogue maths shared by the server (which charges costs) and the browser
// (which displays and disables buttons) so the numbers always agree.
import { effectValue, CITY_DEFENSE_BONUS, ECONOMY } from "./data.js";
import { effectValue, CITY_DEFENSE_BONUS, ECONOMY, POPULARITY, TRENCH } from "./data.js";
// The popularity scale shared by the server (which simulates it) and the
// browser (which shows it). Opinions live on the 0..1 scale; popularity is the
// same figure in integer points centred on neutral 0, and what the player sees
// is a logistic of those points.
export function popularityPoints(opinion) {
return Math.round((opinion - 0.5) / POPULARITY.opinionPerPoint);
}
export function popularityDisplay(points) {
return 1 / (1 + Math.exp(-POPULARITY.logisticScale * points));
}
// The hourly pace at which a population at `points` returns to neutral: one
// point an hour, so the estimate is simply the distance left.
export function hoursToNeutral(points) {
return Math.abs(points);
}
// Fixed cost of raising a building to `level` (0-indexed: the cost of the next
// level). It grows only with the level, never with the nation's GDP.
@@ -100,6 +118,7 @@ export function gdpPerCapitaFactors(tile, {
nextToCity = false,
pillagePenalty = 1,
battleDeficit = 0,
trench = false,
regionalMultiplier = 1,
regionalLabel = null,
nationalMultiplier = 1,
@@ -117,6 +136,9 @@ export function gdpPerCapitaFactors(tile, {
if (pillagePenalty !== 1) {
factors.push({ label: "Pillaging", multiplier: pillagePenalty });
}
if (trench) {
factors.push({ label: "Trenches", multiplier: TRENCH.gdpMultiplier });
}
// City improvements only shape the region their city controls, so a tile's
// GDP per capita carries its region's multiplier plus the nation-wide one
// from government and research. Both are factors on the per-capita figure,
+60
View File
@@ -500,3 +500,63 @@ export class AirTest extends TestCase {
this.assertTrue(state._isStrikeUnit(jet), "an aircraft attacks from the air");
}
}
// A sea tile, unoccupied by hulls, from which a battleship can shell the shore.
function freeSeaNearCoast(state) {
for (const k in state.tiles) {
const coords = parseKey(k);
if (state.tiles[k].terrainClass !== "Sea" || state.unitAt(coords) || state.cityAt(coords)) {
continue;
}
for (const neighbour of state.topology.neighbours(coords.x, coords.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
return { sea: coords, land: { x: neighbour.x, y: neighbour.y } };
}
}
return null;
}
function freeSeaNear(state, coords, maxDistance) {
for (const k in state.tiles) {
const cell = parseKey(k);
if (state.tiles[k].terrainClass !== "Sea" || state.unitAt(cell) || state.cityAt(cell)) continue;
if (state.topology.tileDistance(cell, coords) <= maxDistance) return cell;
}
return null;
}
export class CoastalBombardTest extends TestCase {
test_a_battleship_bombards_a_coastal_unit() {
const state = smallState();
const spot = freeSeaNearCoast(state);
this.assertNotNull(spot, "the map has an open coast");
const battleship = state._spawnUnit(spot.sea, 0, protoUnit(state, "battleship"));
const defender = state._spawnUnit(spot.land, 1, protoUnit(state, "modern_infantry"));
this.assertTrue(
state.requestBombard([battleship.id], defender.coords),
"the warship opens fire on the shore"
);
this.assertEqual(defender.hp, defender.maxHp, "the first salvo waits for the hour");
state.advanceHour();
this.assertLess(defender.hp, defender.maxHp, "the coastal unit is hit");
this.assertEqual(battleship.coords.x, spot.sea.x, "the warship holds station");
this.assertEqual(battleship.coords.y, spot.sea.y);
}
test_a_battleship_shells_a_defenceless_coastal_city() {
const state = smallState();
const city = state.cities.find(
(c) => c.civ === 1 && state.isCoastalCity(c) && state.unitsAt(c.coords).length === 0
);
this.assertNotNull(city, "the enemy has a defenceless coastal city");
const sea = freeSeaNear(state, city.coords, 2);
this.assertNotNull(sea, "there is open water within the guns' reach of the city");
const battleship = state._spawnUnit(sea, 0, protoUnit(state, "battleship"));
const k = `${city.coords.x},${city.coords.y}`;
const before = state.tilePopulation.get(k) || 0;
this.assertTrue(state.requestBombard([battleship.id], city.coords), "the city is a target");
state.advanceHour();
this.assertLess(state.tilePopulation.get(k) || 0, before, "the bombardment killed civilians");
}
}
+17 -5
View File
@@ -393,17 +393,29 @@ export class CombatTest extends TestCase {
this.assertLess(infantry.hp, infantryHp, "the infantry is hit back");
}
test_melee_charges_an_artillery_and_hits_it() {
test_infantry_captures_an_unaccompanied_artillery_it_charges() {
const state = smallState();
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestMove(infantry.id, pair.a), "the infantry charges in");
state.advanceMovement(1000);
this.assertEqual(state._combatTarget(infantry), artillery);
const artilleryHp = artillery.hp;
state._resolveCombatRound();
this.assertLess(artillery.hp, artilleryHp, "an adjacent artillery has almost no protection");
// With no friendly infantry beside it, the battery is taken instead of
// fought, so there is no battle to resolve.
this.assertEqual(artillery.civ, 1, "the artillery is captured");
this.assertNull(state._combatTarget(infantry), "no melee follows the capture");
}
test_artillery_sheltered_by_infantry_still_fights() {
const state = smallState();
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
state._spawnUnit(pair.a, 0, state.protoUnits[0]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestMove(infantry.id, pair.a), "the infantry charges in");
state.advanceMovement(1000);
this.assertEqual(artillery.civ, 0, "the artillery stays in the fight");
this.assertNotNull(state._combatTarget(infantry), "there is a battle to resolve");
}
test_a_battle_removes_a_share_of_population_and_gdp_each_day() {
+19
View File
@@ -16,6 +16,7 @@ import {
STATUS_BATTLE,
PILLAGE,
pillageSteps,
POLICIES,
} from "../shared/data.js";
export class DataTest extends TestCase {
@@ -104,6 +105,24 @@ export class DataTest extends TestCase {
}
}
test_policy_effects_are_popularity_points_not_percentages() {
for (const policy of POLICIES) {
this.assertFalse(
policy.targetEffect.includes("%"),
`${policy.id} target effect is stated in popularity points`
);
this.assertFalse(
policy.politicalCost.includes("%"),
`${policy.id} political cost is stated in popularity points`
);
}
}
test_market_and_bank_are_cheap_starter_buildings() {
this.assertEqual(buildingById("market").baseCost, 500_000);
this.assertEqual(buildingById("bank").baseCost, 1_000_000);
}
test_technologies_form_a_tree() {
const ids = new Set(TECHNOLOGIES.map((t) => t.id));
for (const tech of TECHNOLOGIES) {
+291 -17
View File
@@ -1,7 +1,8 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { GameScreen } from "../client/js/game_screen.js";
import { BUILDINGS, CIVILISATIONS } from "../shared/data.js";
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES } from "../shared/data.js";
import { parseKey } from "../shared/hex.js";
import { smallState, adjacentLand } from "./framework/helpers.js";
function stubNetwork() {
@@ -85,7 +86,7 @@ export class GameScreenTest extends TestCase {
env.$("#city-manage").click();
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
this.assertSize(env.$("#city-train-list button"), 2, "infantry and artillery can be trained");
this.assertSize(env.$("#city-train-list button"), 3, "infantry, artillery and radar can be trained");
env.$("#city-train-list button").first().click();
this.assertEqual(network.orders[0], { type: "train", city: capital.id, proto: 0 });
@@ -115,7 +116,7 @@ export class GameScreenTest extends TestCase {
env.$("#civ-flag").click();
this.assertFalse(env.$("#modal-nation").hasClass("hidden"));
this.assertSize(env.$("#government-list .row-card"), 3);
this.assertSize(env.$("#technology-list .row-card"), 5);
this.assertSize(env.$("#technology-list .row-card"), TECHNOLOGIES.length);
screen.leave();
} finally {
teardownDom(env);
@@ -155,6 +156,45 @@ export class GameScreenTest extends TestCase {
}
}
async test_top_panel_arrows_compare_to_last_month() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
screen.onState(state.snapshot(0));
// January 2000 carries no baseline, so no change markers.
this.assertSize(env.$("#stat-pop .stat-arrow"), 0, "no arrow without a past month");
const snapshot = state.snapshot(0);
const stats = snapshot.viewerStats;
snapshot.monthly = [{
population: stats.population / 2,
gdp: stats.gdp / 2,
gdpPerCapita: stats.gdpPerCapita / 2,
popularity: stats.popularity,
approval: stats.approval,
budget: stats.budget,
}];
screen.onState(snapshot);
this.assertEqual(
env.$("#stat-pop .stat-arrow").text(),
"▲▲",
"a doubling population shows two green up arrows"
);
this.assertTrue(env.$("#stat-pop .stat-arrow").hasClass("up"));
this.assertEqual(
env.$("#stat-approval .stat-arrow").text(),
"=",
"an unchanged popularity shows an equals sign"
);
this.assertTrue(env.$("#stat-approval .stat-arrow").hasClass("equal"));
screen.leave();
} finally {
teardownDom(env);
}
}
async test_buildings_and_nation_modals_refresh_on_snapshot() {
const env = await setupDom();
try {
@@ -479,7 +519,7 @@ export class GameScreenTest extends TestCase {
screen.map.onCitySelected(capital.id);
env.$("#city-manage").click();
const before = env.$("#city-train-list button").length;
this.assertEqual(before, 2, "only infantry and artillery are trainable at first");
this.assertEqual(before, 3, "only infantry, artillery and radar are trainable at first");
// The airport is completed server-side and a fresh snapshot arrives while
// the panel is still open.
@@ -510,7 +550,7 @@ export class GameScreenTest extends TestCase {
screen.map.onCitySelected(capital.id);
env.$("#city-manage").click();
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
this.assertSize(env.$("#city-train-list .free-button"), 2, "a free button per trainable unit");
this.assertSize(env.$("#city-train-list .free-button"), 3, "a free button per trainable unit");
env.$("#city-train-list .free-button").first().click();
this.assertEqual(network.orders[0], {
type: "train",
@@ -692,10 +732,10 @@ export class GameScreenTest extends TestCase {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
state.research.set(0, 1.0e6);
state.budgets.set(0, 1.0e15);
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
state.requestResearchTechnology(0, method);
state.requestFocusTechnology(0, method);
state._addResearch(0, state.getTechnologyCost(0, method));
screen.onState(state.snapshot(0));
this.assertTrue(screen._researched.has(method), "researched technologies are mirrored");
@@ -780,6 +820,57 @@ export class GameScreenTest extends TestCase {
}
}
async test_unit_panel_shows_transport_cargo_and_boarding() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const launchProto = state.protoUnits.find((u) => u.id === "launch");
const infantryProto = state.protoUnits.find((u) => u.id === "modern_infantry");
const seaKey = Object.keys(state.tiles).find(
(k) => state.tiles[k].terrainClass === "Sea"
);
this.assertNotNull(seaKey, "the map has a sea tile");
const spot = parseKey(seaKey);
const launch = state._spawnUnit(spot, 0, launchProto);
launch.cargo = [{ proto: state.protoUnits.indexOf(infantryProto), hp: infantryProto.maxHp }];
launch.embarking = { ids: [99], hoursLeft: 6 };
const snapshot = state.snapshot(0);
screen.onState(snapshot);
screen._showUnit(snapshot.units.find((u) => u.id === launch.id));
const cargo = env.$("#unit-cargo");
this.assertFalse(cargo.hasClass("hidden"));
this.assertTrue(cargo.text().includes("Cargo 1/1"), "the berths are shown");
this.assertTrue(cargo.text().includes(infantryProto.name), "the passenger is named");
this.assertTrue(cargo.text().includes("boarding 1"), "a boarding party is shown");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_unit_panel_hides_cargo_for_a_plain_unit() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const spot = state.landCells.find(
(c) => state.tiles[`${c.x},${c.y}`].terrainClass === "Land" && !state.cityAt(c)
);
const unit = state._spawnUnit(spot, 0, state.protoUnits[0]);
const snapshot = state.snapshot(0);
screen.onState(snapshot);
screen._showUnit(snapshot.units.find((u) => u.id === unit.id));
this.assertTrue(env.$("#unit-cargo").hasClass("hidden"));
screen.leave();
} finally {
teardownDom(env);
}
}
async test_move_into_peaceful_territory_prompts_for_war() {
const env = await setupDom();
try {
@@ -874,6 +965,30 @@ export class GameScreenTest extends TestCase {
}
}
async test_focusing_a_technology_sends_the_order() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab=technology]").click();
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
env.$("#technology-list .row-card")
.eq(method)
.find(".actions button")
.click();
this.assertEqual(network.orders[0], {
type: "focus_technology",
technology: method,
});
screen.leave();
} finally {
teardownDom(env);
}
}
async test_news_feed_shows_declarations() {
const env = await setupDom();
try {
@@ -1278,7 +1393,7 @@ export class GameScreenTest extends TestCase {
}
}
async test_build_button_opens_the_improvement_picker_and_sends_an_order() {
async test_improvement_buttons_send_build_and_remove_orders() {
const env = await setupDom();
try {
const network = stubNetwork();
@@ -1294,13 +1409,20 @@ export class GameScreenTest extends TestCase {
});
this.assertNotNull(coords, "the player owns a bare land tile");
screen._showTile(coords);
this.assertFalse(env.$("#tile-build").hasClass("hidden"), "the build button is offered");
env.$("#tile-build").click();
const options = env.$("#tile-build-popover .build-option");
const options = env.$("#tile-improvement-actions .transport-btn");
this.assertEqual(options.length, 2, "road and railway are listed");
this.assertFalse(options.eq(0).prop("disabled"), "the road option is clickable");
this.assertFalse(options.eq(1).prop("disabled"), "the railway option is clickable");
// Clicking the road choice (through the delegated handler) sends the order.
// Each is a square icon with its name in the tooltip and cost beneath.
this.assertTrue(
options.eq(0).find("img.improvement-icon").attr("src").endsWith("icon_road.svg"),
"the road button shows its icon"
);
this.assertEqual(options.eq(0).find(".improvement-cost").text(), "€50M", "cost under the icon");
this.assertTrue(
options.eq(0).attr("title").startsWith("Road"),
"the name is the tooltip"
);
options.eq(0).click();
const order = network.orders[network.orders.length - 1];
this.assertEqual(order.type, "build_improvement");
@@ -1308,15 +1430,167 @@ export class GameScreenTest extends TestCase {
this.assertApprox(order.coords[1], coords.y);
this.assertEqual(order.improvement, "road");
// A tile that already carries the improvement offers only the upgrade.
// A tile that already carries the improvement greys it out, keeps the
// upgrade and offers a remove control.
const road = Array.from(state.roads).map((k) => k.split(",").map(Number))[0];
screen._showTile({ x: road[0], y: road[1] });
env.$("#tile-build").click();
const again = env.$("#tile-build-popover .build-option");
const again = env.$("#tile-improvement-actions .improvement-btn").not(".remove");
this.assertTrue(again.eq(0).prop("disabled"), "the built road is greyed out");
this.assertFalse(again.eq(1).prop("disabled"), "the railway upgrade stays available");
again.eq(1).click();
this.assertEqual(network.orders[network.orders.length - 1].improvement, "railway");
const $remove = env.$("#tile-improvement-actions .improvement-btn.remove");
this.assertSize($remove, 1, "a remove control is offered");
$remove.click();
this.assertEqual(network.orders[network.orders.length - 1], {
type: "remove_improvement",
coords: [road[0], road[1]],
});
screen.leave();
} finally {
teardownDom(env);
}
}
async test_tile_panel_shows_improvement_build_progress() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
state.budgets.set(0, 1.0e15);
const coords = state.landCells.find((cell) => {
const k = `${cell.x},${cell.y}`;
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.unitAt(cell);
});
this.assertNotNull(coords, "the player owns a bare land tile");
this.assertTrue(
state.requestBuildTileImprovement(0, coords, "radar_tower"),
"the work is queued"
);
state.advanceHour();
screen.onState(state.snapshot(0));
screen._showTile(coords);
const label = env.$("#tile-works .city-production-label").text();
this.assertTrue(label.startsWith("Building Radar tower"), `the work is named: ${label}`);
this.assertGreater(env.$("#tile-works .progress").length, 0, "a progress bar is drawn");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_double_clicking_a_city_opens_its_panel() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
screen.onState(state.snapshot(0));
const marker = env.$(`#layer-entities .city[data-city-id='${capital.id}'] .marker`).get(0);
this.assertNotNull(marker, "the capital is drawn");
// One click selects the city and shows its quick panel.
env.$(marker).trigger("click");
this.assertFalse(env.$("#city-info").hasClass("hidden"), "a click opens the quick panel");
this.assertTrue(env.$("#modal-city").hasClass("hidden"), "a click does not open the modal");
// The second click opens the full city panel.
marker.dispatchEvent(
new env.window.MouseEvent("dblclick", { bubbles: true, cancelable: true })
);
this.assertFalse(env.$("#modal-city").hasClass("hidden"), "the city panel opens");
this.assertTrue(
env.$("#city-title").text().startsWith(capital.name),
"the panel is for the clicked city"
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_clicking_a_unit_also_opens_its_tile_panel() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
screen.onState(state.snapshot(0));
screen.map.onUnitSelected(unit.id);
this.assertFalse(env.$("#unit-panel").hasClass("hidden"), "the unit panel opens");
this.assertFalse(env.$("#tile-panel").hasClass("hidden"), "the tile panel opens too");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_stack_panel_checklist_narrows_the_selection() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
screen._selectUnits([unit.id, second.id]);
this.assertSize(env.$("#unit-stack-list input"), 2, "each stacked unit is listed");
env.$("#unit-stack-list input").eq(0).prop("checked", false).trigger("change");
this.assertEqual(screen.selectedUnitIds.length, 1, "unticking narrows the selection");
this.assertEqual(screen.selectedUnitIds[0], second.id);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_city_tile_panel_shows_current_production() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
state.budgets.set(0, 1.0e15);
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
state.requestTrain(capital.id, 0);
state.training.get(capital.id)[0].elapsedHours = state.training.get(capital.id)[0].totalHours / 2;
screen.onState(state.snapshot(0));
screen.map.onCitySelected(capital.id);
this.assertTrue(
env.$("#city-production").text().includes("Training"),
"the city panel shows what is in production"
);
this.assertTrue(
parseFloat(env.$("#city-production .city-production-bar > div").css("width")) > 0,
"the quick view has a progress bar"
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_build_tooltip_forecasts_the_income_gain() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
state.budgets.set(0, 1.0e15);
screen.onState(state.snapshot(0));
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
screen.map.onCitySelected(capital.id);
env.$("#city-manage").click();
env.$("#modal-city .tab[data-tab='buildings']").click();
const bank = state.protoBuildings.findIndex((b) => b.id === "bank");
const $upgrade = env.$("#city-buildings-list .row-card")
.eq(bank)
.find("button")
.first();
this.assertTrue(
($upgrade.attr("title") || "").includes("Expected budget income"),
"the upgrade button forecasts the budget gain"
);
screen.leave();
} finally {
teardownDom(env);
+3 -3
View File
@@ -272,11 +272,11 @@ export class GameServerOrdersTest extends TestCase {
this.assertEqual(server.state.getGovernment(0), 1);
}
test_technology_order() {
test_focus_technology_order() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
server.state.research.set(0, 1.0e6);
this.assertTrue(server.handleOrder(1, { type: "technology", technology: 0 }));
this.assertTrue(server.handleOrder(1, { type: "focus_technology", technology: 0 }));
this.assertEqual(server.state.getFocusedTechnology(0), 0);
}
test_move_order_with_bad_coords_is_rejected_cleanly() {
+8 -5
View File
@@ -125,13 +125,16 @@ export class GameStateBuildingsTest extends TestCase {
building.effects.push({ stat: EFFECT_RESEARCH, baseValue: 2.0, exponent: 1.0 });
const index = this.addBuilding(state, building);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
this.assertEqual(state.getResearch(0), 0);
// Research only accumulates into a focused technology, so focus one.
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
this.assertTrue(state.requestFocusTechnology(0, method));
this.assertEqual(state.getTechnologyProgress(0), 0);
state.advanceHour();
const first = state.getResearch(0);
const first = state.getTechnologyProgress(0);
const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0;
this.assertApprox(first - governmentRate, 2.0);
state.advanceHour();
this.assertApprox(state.getResearch(0) - first - governmentRate, 2.0);
this.assertApprox(state.getTechnologyProgress(0) - first - governmentRate, 2.0);
}
test_viewer_stats_split_income_and_upkeep() {
@@ -191,10 +194,10 @@ export class GameStateBuildingsTest extends TestCase {
const state = smallState();
const snapshot = state.snapshot(0);
this.assertSize(snapshot.buildings, state.protoBuildings.length);
this.assertHas(snapshot, "research");
this.assertHas(snapshot, "focusTechnology");
this.assertHas(snapshot, "culture");
for (const city of snapshot.cities) this.assertHas(city, "buildings");
this.assertHas(snapshot.viewerStats, "research");
this.assertHas(snapshot.viewerStats, "researchRate");
this.assertHas(snapshot.viewerStats, "culture");
}
+4 -2
View File
@@ -50,9 +50,11 @@ export class GameStateGovernmentTest extends TestCase {
state.budgets.set(0, BIG_BUDGET);
const technocracy = state.governments.findIndex((g) => g.id === "technocracy");
this.assertTrue(state.requestSetGovernment(0, technocracy));
this.assertEqual(state.getResearch(0), 0);
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
this.assertTrue(state.requestFocusTechnology(0, method));
this.assertEqual(state.getTechnologyProgress(0), 0);
state.advanceHour();
this.assertApprox(state.getResearch(0), 3.0);
this.assertApprox(state.getTechnologyProgress(0), 3.0);
}
test_snapshot_exposes_government() {
+131 -42
View File
@@ -1,14 +1,22 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { TECHNOLOGIES } from "../shared/data.js";
const BIG_RESEARCH = 100000.0;
import { TECHNOLOGIES, EFFECT_INFANTRY_ATTACK, EFFECT_CONSTRUCTION_SPEED } from "../shared/data.js";
import { NEWS_TECHNOLOGY } from "../shared/data/relations.js";
export class GameStateTechnologyTest extends TestCase {
index(state, id) {
return state.technologies.findIndex((t) => t.id === id);
}
// Focuses `id` and pours exactly its cost of points into it, the way a nation
// would over time; returns the index.
complete(state, id, civ = 0) {
const index = this.index(state, id);
this.assertTrue(state.requestFocusTechnology(civ, index), `focusing ${id}`);
state._addResearch(civ, state.getTechnologyCost(civ, index));
return index;
}
test_catalogue_links_prerequisites() {
const method = TECHNOLOGIES.find((t) => t.id === "scientific_method");
this.assertEmpty(method.prerequisites);
@@ -24,79 +32,160 @@ export class GameStateTechnologyTest extends TestCase {
this.assertEmpty(state.getResearchedIndices(0));
}
test_request_research_spends_points_and_marks_it_known() {
test_research_points_fill_the_focused_technology() {
const state = smallState();
const index = this.index(state, "scientific_method");
state.research.set(0, BIG_RESEARCH);
const cost = state.technologies[index].cost;
this.assertTrue(state.requestResearchTechnology(0, index));
this.assertTrue(state.isResearched(0, index));
this.assertApprox(state.getResearch(0), BIG_RESEARCH - cost);
this.assertFalse(state.requestResearchTechnology(0, index), "already known");
this.assertTrue(state.requestFocusTechnology(0, index), "the focus is accepted");
const cost = state.getTechnologyCost(0, index);
// Points flow in as they are created; just short of the cost leaves it in
// progress rather than in a bank.
state._addResearch(0, cost - 1);
this.assertFalse(state.isResearched(0, index), "still researching");
this.assertApprox(state.getTechnologyProgress(0), cost - 1, 1e-6);
state._addResearch(0, 1);
this.assertTrue(state.isResearched(0, index), "the technology is discovered");
this.assertEqual(state.getFocusedTechnology(0), -1, "the focus clears");
}
test_request_research_rejects_unknown_and_unaffordable() {
test_switching_focus_keeps_committed_progress() {
const state = smallState();
state.research.set(0, BIG_RESEARCH);
this.assertFalse(state.requestResearchTechnology(0, 999), "unknown technology");
this.assertFalse(state.requestResearchTechnology(999, 0), "unknown civilisation");
state.research.set(0, 0);
this.assertFalse(state.requestResearchTechnology(0, 0), "cannot afford it");
this.complete(state, "scientific_method");
const weapons = this.index(state, "infantry_weapons");
const armour = this.index(state, "body_armour");
this.assertTrue(state.requestFocusTechnology(0, weapons));
state._addResearch(0, 7);
this.assertApprox(state.getTechnologyProgress(0, weapons), 7, 1e-9);
// Switch focus: the weapons points stay committed.
this.assertTrue(state.requestFocusTechnology(0, armour));
this.assertApprox(state.getTechnologyProgress(0, weapons), 7, 1e-9, "weapons kept its work");
this.assertApprox(state.getTechnologyProgress(0, armour), 0, 1e-9);
state._addResearch(0, 5);
this.assertApprox(state.getTechnologyProgress(0, armour), 5, 1e-9);
// Switching back resumes from the seven points already committed.
this.assertTrue(state.requestFocusTechnology(0, weapons));
this.assertApprox(state.getTechnologyProgress(0, weapons), 7, 1e-9);
this.assertEqual(state.getTechnologyProgress(0), 7, "the focus reports its own progress");
this.assertApprox(state.snapshot(0).techProgress[0][weapons], 7, 1e-9, "progress is shipped");
}
test_request_research_rejects_when_prerequisites_are_missing() {
test_nothing_is_researched_without_a_focus() {
const state = smallState();
const index = this.index(state, "scientific_method");
// Points created with nothing focused are simply not stored anywhere.
state._addResearch(0, state.getTechnologyCost(0, index) * 10);
this.assertFalse(state.isResearched(0, index));
this.assertApprox(state.getTechnologyProgress(0), 0, 1e-9);
}
test_focus_rejects_unknown_technology_and_civilisation() {
const state = smallState();
this.assertFalse(state.requestFocusTechnology(0, 999), "unknown technology");
this.assertFalse(state.requestFocusTechnology(999, 0), "unknown civilisation");
}
test_focus_rejects_when_prerequisites_are_missing() {
const state = smallState();
state.research.set(0, BIG_RESEARCH);
const space = this.index(state, "space_program");
this.assertFalse(state.canResearchTechnology(0, space));
this.assertFalse(state.requestResearchTechnology(0, space));
this.assertFalse(state.requestFocusTechnology(0, space));
}
test_prerequisites_unlock_the_tree() {
const state = smallState();
state.research.set(0, BIG_RESEARCH);
const method = this.index(state, "scientific_method");
const automation = this.index(state, "industrial_automation");
const quantum = this.index(state, "quantum_computing");
const space = this.index(state, "space_program");
this.assertFalse(state.requestResearchTechnology(0, automation), "locked before its prerequisite");
this.assertTrue(state.requestResearchTechnology(0, method));
this.assertTrue(state.requestResearchTechnology(0, automation));
this.assertTrue(state.requestResearchTechnology(0, quantum));
this.assertTrue(state.requestResearchTechnology(0, space));
this.assertFalse(state.requestFocusTechnology(0, automation), "locked before its prerequisite");
this.complete(state, "scientific_method");
this.assertFalse(state.isResearched(0, automation), "a prerequisite is not auto-granted");
this.complete(state, "industrial_automation");
this.assertTrue(state.isResearched(0, automation));
this.complete(state, "quantum_computing");
this.assertTrue(state.requestFocusTechnology(0, this.index(state, "space_program")));
}
test_technology_effects_are_folded_into_gdp() {
const state = smallState();
state.research.set(0, BIG_RESEARCH);
const before = state.getPlayerGdp(0);
const method = this.index(state, "scientific_method");
this.assertTrue(state.requestResearchTechnology(0, method));
this.complete(state, "scientific_method");
this.assertApprox(state.getPlayerGdp(0), (before * 1.07) / 1.05, 0.01);
}
test_researched_technology_adds_its_research_rate() {
const withTech = smallState();
const without = smallState();
withTech.research.set(0, BIG_RESEARCH);
const method = this.index(withTech, "scientific_method");
this.assertTrue(withTech.requestResearchTechnology(0, method));
const base = withTech.getResearch(0);
withTech.advanceHour();
without.advanceHour();
const delta = withTech.getResearch(0) - base - without.getResearch(0);
this.assertApprox(delta, 1.0);
this.complete(withTech, "scientific_method");
this.assertApprox(
withTech.viewerStats(0).researchRate - without.viewerStats(0).researchRate,
1.0,
1e-9
);
}
test_snapshot_exposes_technologies_and_progress() {
const state = smallState();
state.research.set(0, BIG_RESEARCH);
const index = this.index(state, "scientific_method");
this.assertTrue(state.requestResearchTechnology(0, index));
const index = this.complete(state, "scientific_method");
const snapshot = state.snapshot(0);
this.assertSize(snapshot.technologies, state.technologies.length);
this.assertSize(snapshot.researched, state.civilisations.length);
this.assertTrue(snapshot.researched[0].includes(index));
this.assertEmpty(snapshot.researched[1]);
}
test_repeatable_research_costs_two_percent_more_each_level() {
const state = smallState();
state.researched.get(0).add(this.index(state, "scientific_method"));
const index = this.index(state, "infantry_weapons");
const base = state.getTechnologyCost(0, index);
this.assertTrue(state.requestFocusTechnology(0, index), "first level");
state._addResearch(0, base);
this.assertEqual(state.getTechnologyRepeatCount(0, index), 1);
this.assertApprox(
state.getTechnologyCost(0, index),
base * 1.02,
1e-6,
"the next level costs 2% more"
);
this.assertTrue(state.requestFocusTechnology(0, index), "second level");
state._addResearch(0, state.getTechnologyCost(0, index));
this.assertEqual(state.getTechnologyRepeatCount(0, index), 2);
this.assertApprox(state.getTechnologyCost(0, index), base * 1.02 * 1.02, 1e-6);
}
test_repeatable_effects_stack_with_each_level() {
const state = smallState();
state.researched.get(0).add(this.index(state, "scientific_method"));
const weapons = this.index(state, "infantry_weapons");
const construction = this.index(state, "construction_technology");
for (let level = 0; level < 2; level++) {
state.requestFocusTechnology(0, weapons);
state._addResearch(0, state.getTechnologyCost(0, weapons));
}
state.requestFocusTechnology(0, construction);
state._addResearch(0, state.getTechnologyCost(0, construction));
const modifiers = state.getCivModifiers(0);
this.assertApprox(modifiers[EFFECT_INFANTRY_ATTACK], 0.02, 1e-9, "two levels stack");
this.assertApprox(modifiers[EFFECT_CONSTRUCTION_SPEED], 0.01, 1e-9);
this.assertApprox(state.constructionSpeedMultiplier(0), 1 / 1.01, 1e-9);
}
test_repeatable_research_notifies_without_news() {
const state = smallState();
this.complete(state, "scientific_method");
this.complete(state, "body_armour");
const notices = state.news.filter((entry) => entry.type === NEWS_TECHNOLOGY);
const normalNotice = notices.find((entry) => entry.technology === "scientific_method");
const repeatNotice = notices.find((entry) => entry.technology === "body_armour");
this.assertNotNull(normalNotice);
this.assertFalse(!!normalNotice.repeatable, "a discovery is a real news event");
this.assertNotNull(repeatNotice);
this.assertTrue(repeatNotice.repeatable, "a repeat level is only a notice");
}
test_repeatable_technologies_have_a_theme() {
for (const proto of TECHNOLOGIES) {
this.assertTrue(
typeof proto.theme === "string" && proto.theme.length > 0,
`${proto.id} is filed under a theme`
);
}
}
}
+2 -2
View File
@@ -289,7 +289,7 @@ export class GameStateTest extends TestCase {
for (const key of [
"seed", "viewer", "totalHours", "civs", "protos", "buildings", "governments",
"technologies", "cities", "units", "territory", "population", "budgets",
"research", "culture", "government", "researched", "training", "viewerStats",
"culture", "government", "researched", "focusTechnology", "training", "viewerStats",
"explored", "visible",
]) {
this.assertHas(snapshot, key);
@@ -324,7 +324,7 @@ export class GameStateTest extends TestCase {
const second = state.snapshot(1);
for (const key of [
"seed", "totalHours", "civs", "protos", "buildings", "governments", "technologies",
"cities", "units", "territory", "population", "budgets", "research", "culture",
"cities", "units", "territory", "population", "budgets", "culture",
"government", "researched", "training",
]) {
this.assertEqual(first[key], second[key], `key ${key} is shared`);
+28 -5
View File
@@ -142,15 +142,38 @@ export class ImprovementsTest extends TestCase {
);
}
test_a_city_starts_with_a_road_the_player_could_upgrade() {
test_an_improvement_can_be_removed_for_free() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const coords = bareOwnedLand(state);
const k = key(coords.x, coords.y);
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
const before = state.getBudget(0);
this.assertTrue(state.requestRemoveImprovement(0, coords), "the railway is removed");
this.assertFalse(state.railways.has(k), "the tile is bare again");
this.assertApprox(state.getBudget(0), before, 1e-9, "removal is free");
this.assertFalse(state.requestRemoveImprovement(0, coords), "nothing left to remove");
}
test_only_the_controller_may_remove_an_improvement() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const coords = bareOwnedLand(state);
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
this.assertFalse(
state.requestRemoveImprovement(1, coords),
"another nation cannot tear up the road"
);
this.assertTrue(state.roads.has(key(coords.x, coords.y)));
}
test_a_city_is_automatically_improved_with_a_railway() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const city = cityOf(state, 0);
const coords = { x: city.coords.x, y: city.coords.y };
const k = key(coords.x, coords.y);
this.assertTrue(state.roads.has(k), "a city carries a road");
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
this.assertTrue(state.railways.has(k), "the city road becomes a railway");
this.assertFalse(state.roads.has(k));
this.assertTrue(state.railways.has(k), "a city carries a railway");
this.assertFalse(state.roads.has(k), "not a road");
}
}
+26
View File
@@ -48,6 +48,32 @@ export class MapModesTest extends TestCase {
this.assertTrue(/^#[0-9a-f]{6}$/.test(mid), `a valid colour: ${mid}`);
}
async test_the_population_scale_is_logarithmic() {
const scale = ECONOMIC_MODES.population.scale;
this.assertTrue(typeof scale === "function", "population reads through a log scale");
const linear = economicFraction(100000, 1000, 1000000);
const logged = economicFraction(100000, 1000, 1000000, scale);
this.assertGreater(logged, linear, "the log scale lifts smaller towns off the floor");
// A second MapView mode's figures reach the same scale through the legend.
const state = smallState();
const env = await setupDom();
const map = this.buildMap(env);
try {
map.applySnapshot(state.snapshot(0));
map.setMapMode("population");
map.setEconomicData("population", new Map(), 1000, 1000000);
this.assertEqual(map.economicScale, scale, "the active scale reaches the renderer");
const mid = Math.round(Math.expm1((Math.log1p(1000) + Math.log1p(1000000)) / 2));
this.assertEqual(
env.$("#map-legend-mid").text(),
ECONOMIC_MODES.population.format(mid),
"the legend's midpoint is the geometric middle"
);
} finally {
teardownDom(env);
}
}
async test_every_economic_mode_has_a_label_and_formatter() {
for (const id of ["gdp", "population"]) {
const meta = ECONOMIC_MODES[id];
+95 -5
View File
@@ -98,7 +98,7 @@ export class MapViewTest extends TestCase {
this.assertSize(map.roads, snapshot.roads.length, "the snapshot's road tiles are mirrored");
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const capitalKey = `${capital.coords.x},${capital.coords.y}`;
this.assertTrue(map.roads.has(capitalKey), "a city carries a road");
this.assertTrue(map.railways.has(capitalKey), "a city carries a railway");
this.assertGreater(env.$("#layer-roads .road-seg").length, 0, "roads are drawn in the chunks");
} finally {
teardownDom(env);
@@ -489,7 +489,7 @@ export class MapViewTest extends TestCase {
}
}
async test_draws_a_bombardment_line_and_ring_when_the_battery_is_selected() {
async test_draws_a_bombardment_line_and_ring_always_visible() {
const state = smallState();
const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c));
const artillery = state._spawnUnit(
@@ -506,13 +506,31 @@ export class MapViewTest extends TestCase {
try {
map.setSelectedUnit(artillery.id);
map.animate(0);
this.assertSize(env.$("#layer-targets .target-line"), 1, "the line is drawn");
const path = env.$("#layer-targets .target-line").get(0);
const d = path.getAttribute("d");
this.assertTrue(d.includes("Q"), "the barrage is drawn as a curve, not a straight line");
// The control point is lifted above both ends, so the shell arcs upwards.
const startY = Number(d.split(" ")[2]);
const controlY = Number(d.split(" ")[5]);
this.assertLess(controlY, startY, "the arc rises above the battery");
this.assertSize(env.$("#layer-targets .target-shell"), 1, "a shell travels the arc");
this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed");
this.assertSize(
env.$("#layer-targets .target-selected"),
2,
"a selected barrage stands out"
);
// A standing barrage stays visible even when nothing is selected.
map.clearSelection();
map.animate(0);
this.assertSize(env.$("#layer-targets .target-line"), 0, "the line clears with selection");
this.assertSize(env.$("#layer-targets .target-ring"), 0, "the ring clears with selection");
this.assertSize(env.$("#layer-targets .target-line"), 1, "the line stays drawn");
this.assertSize(env.$("#layer-targets .target-ring"), 1, "the ring stays drawn");
this.assertSize(
env.$("#layer-targets .target-selected"),
0,
"nothing is highlighted without a selection"
);
} finally {
teardownDom(env);
}
@@ -796,4 +814,76 @@ export class MapViewTest extends TestCase {
teardownDom(env);
}
}
async test_city_name_carries_a_production_progress_bar() {
const state = smallState();
state.budgets.set(0, 1.0e15);
const city = state.cities.find((c) => c.civ === 0);
state.requestTrain(city.id, 0);
state.training.get(city.id)[0].elapsedHours = state.training.get(city.id)[0].totalHours / 4;
const { env, map } = await this.build(state);
try {
const bar = map._cityViews.get(city.id).querySelector(".city-progress");
this.assertNotNull(bar, "the city has a progress bar");
this.assertNotEqual(bar.style.display, "none", "the bar is shown while producing");
this.assertTrue(parseFloat(bar.querySelector(".fill").style.width) > 0, "it is filled");
this.assertFalse(bar.classList.contains("building"), "a unit queues a blue bar");
} finally {
teardownDom(env);
}
}
async test_each_building_level_adds_a_square_around_the_city() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 0);
city.buildings[0] = 2;
city.buildings[1] = 1;
const { env, map } = await this.build(state);
try {
const squares = map._cityViews.get(city.id).querySelectorAll(".city-building-square");
this.assertEqual(squares.length, 3, "one square per building level");
// The first square's angle and radius come straight from the seed hash.
const angle = map._cityHash(city.id, 0, 0) * Math.PI * 2;
const radius = 0.55 + map._cityHash(city.id, 0, 1) * 0.4;
this.assertApprox(
parseFloat(squares[0].style.left),
50 + Math.cos(angle) * radius * 100,
1e-6,
"the scatter is a deterministic function of the map seed"
);
} finally {
teardownDom(env);
}
}
async test_a_moving_unit_splits_from_its_stack_at_once() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
const { env, map } = await this.build(state);
try {
const tile = { x: unit.coords[0], y: unit.coords[1] };
map._unitMotion.get(unit.id).path = [tile, { x: tile.x + 1, y: tile.y }];
map._unitMotion.get(unit.id).segmentHours = [1];
map._unitMotion.get(unit.id).pathHours = 0.1;
map._assignStackOffsets();
this.assertEqual(
map._unitViews.get(unit.id).style.visibility,
"",
"the moving unit is drawn on its own"
);
this.assertEqual(
map._unitViews.get(second.id).style.visibility,
"",
"the unit left behind stays visible"
);
this.assertEqual(
map._unitViews.get(second.id).querySelector(".unit-count").style.display,
"none",
"the stack badge is gone the moment one member leaves"
);
} finally {
teardownDom(env);
}
}
}
+81 -3
View File
@@ -244,7 +244,7 @@ export class NationModalTest extends TestCase {
modal.onGovernment = (index) => {
government = index;
};
modal.onTechnology = (index) => {
modal.onFocusTechnology = (index) => {
technology = index;
};
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6);
@@ -265,7 +265,7 @@ export class NationModalTest extends TestCase {
// Researching its prerequisite unlocks it.
modal.refresh(0, new Set([method]), 1.0e9, 1.0e15, 1.0e6);
this.assertTrue(techRows.eq(method).find(".actions button").text().startsWith("Researched"));
this.assertTrue(techRows.eq(automation).find(".actions button").text().startsWith("Research"));
this.assertTrue(techRows.eq(automation).find(".actions button").text().startsWith("Focus"));
techRows.eq(automation).find(".actions button").click();
this.assertEqual(technology, automation);
@@ -280,12 +280,90 @@ export class NationModalTest extends TestCase {
}
}
async test_technology_theme_tabs_filter_the_list() {
const env = await setupDom();
try {
const modal = new NationModal();
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6);
const $themes = env.$("#technology-themes .subtab");
this.assertGreater($themes.length, 1, "the technologies are split into themes");
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
const automation = TECHNOLOGIES.findIndex((t) => t.id === "industrial_automation");
const rows = env.$("#technology-list .row-card");
this.assertNotEqual(rows.eq(method).css("display"), "none", "science is the opening theme");
this.assertEqual(rows.eq(automation).css("display"), "none", "other themes start hidden");
$themes.filter((_index, element) => env.$(element).text() === "Economy").trigger("click");
this.assertEqual(rows.eq(method).css("display"), "none", "science is hidden");
this.assertNotEqual(rows.eq(automation).css("display"), "none", "the economy theme shows");
modal.close();
} finally {
teardownDom(env);
}
}
async test_a_repeatable_technology_shows_its_level() {
const env = await setupDom();
try {
const modal = new NationModal();
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set([method]), 1.0e9, 1.0e15, 1.0e6, {}, null, [], null, {
focus: -1,
progress: 0,
repeatCounts: { infantry_weapons: 3 },
});
const index = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons");
const name = env.$("#technology-list .row-card").eq(index).find(".name").text();
this.assertTrue(name.includes("Level 3"), `the level is shown: ${name}`);
const text = env.$("#technology-list .row-card").eq(index).find(".cost").text();
this.assertTrue(text.startsWith("Cost"), "the next level is priced");
modal.close();
} finally {
teardownDom(env);
}
}
async test_a_focused_technology_shows_a_progress_bar() {
const env = await setupDom();
try {
const modal = new NationModal();
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5, {}, null, [], null, {
focus: method,
progress: 10,
repeatCounts: {},
});
const card = env.$("#technology-list .row-card").eq(method);
this.assertTrue(card.find(".tech-progress-track").length > 0, "the focus has a bar");
this.assertEqual(card.find(".tech-progress-text").text(), "10 / 20", "X / Y beside it");
this.assertEqual(card.find(".actions button").text(), "Focused", "no cost on the button");
modal.close();
} finally {
teardownDom(env);
}
}
async test_military_technology_effects_are_readable_text() {
const env = await setupDom();
try {
const modal = new NationModal();
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5);
const infantry = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons");
this.assertEqual(
env.$("#technology-list .row-card").eq(infantry).find(".effects").text(),
"+1.0% infantry attack"
);
modal.close();
} finally {
teardownDom(env);
}
}
async test_reopening_rebuilds_rows_instead_of_stacking_them() {
const env = await setupDom();
try {
const modal = new NationModal();
let technology = null;
modal.onTechnology = (index) => {
modal.onFocusTechnology = (index) => {
technology = index;
};
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 0);
+46
View File
@@ -0,0 +1,46 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
export class MonthlyTest extends TestCase {
test_january_2000_has_no_month_to_compare_against() {
const state = smallState();
this.assertNull(state.monthlyStats, "the first month starts with no baseline");
const snapshot = state.snapshot(0);
this.assertSize(snapshot.monthly, 0, "the snapshot ships an empty baseline in January");
}
test_a_rolled_over_month_becomes_the_baseline() {
const state = smallState();
// January has 31 days; stepping past its last hour rolls the month over.
for (let i = 0; i < 31 * HOURS_PER_DAY; i++) state.tickHour();
this.assertNotNull(state.monthlyStats, "February has January to compare against");
this.assertSize(
state.monthlyStats,
state.civilisations.length,
"every nation carries a baseline"
);
for (const entry of state.monthlyStats) {
this.assertTrue(typeof entry.population === "number", "the population is captured");
this.assertTrue(typeof entry.gdp === "number", "the GDP is captured");
this.assertTrue(typeof entry.gdpPerCapita === "number", "the GDP/capita is captured");
this.assertTrue(typeof entry.popularity === "number", "the popularity is captured");
this.assertTrue(typeof entry.budget === "number", "the budget is captured");
}
const snapshot = state.snapshot(0);
this.assertSize(snapshot.monthly, state.civilisations.length, "the snapshot ships the baseline");
}
test_the_baseline_is_the_month_that_just_ended() {
const state = smallState();
const startPopulation = state.snapshot(0).viewerStats.population;
for (let i = 0; i < 31 * HOURS_PER_DAY; i++) state.tickHour();
// The baseline is the reading at the start of January, not the live one.
this.assertApprox(
state.monthlyStats[0].population,
startPopulation,
1e-6,
"the baseline is January's opening figure"
);
}
}
+91 -2
View File
@@ -126,7 +126,8 @@ export class PoliticsModelTest extends TestCase {
const near = smallState();
near.budgets.set(0, BIG_BUDGET);
near._shiftGovOpinion(0, 1, 0.4);
// Close enough to the ceiling that the drift nearly cancels the campaign.
near._shiftGovOpinion(0, 1, 0.05);
const nearStart = near.getGovOpinion(0, 1);
near.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1);
daysPass(near, 10);
@@ -150,7 +151,8 @@ export class PoliticsModelTest extends TestCase {
const near = smallState();
near.budgets.set(0, BIG_BUDGET);
near._shiftGovOpinion(0, 1, -0.4);
// Close enough to the floor that the drift nearly cancels the campaign.
near._shiftGovOpinion(0, 1, -0.05);
const nearStart = near.getGovOpinion(0, 1);
near.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, -1);
daysPass(near, 10);
@@ -483,6 +485,93 @@ export class PoliticsModelTest extends TestCase {
this.assertGreater(moved.count, 0, "the tile still counts that people");
this.assertLess(moved.trend, 0, "the tile lost some of them");
}
test_opinions_drift_one_point_an_hour_toward_neutral() {
const state = smallState();
// Ten points (0.05 on the opinion scale) comes home in ten hours.
state._shiftGovOpinion(0, 1, 10 * 0.005);
for (let i = 0; i < 9; i++) state.tickHour();
this.assertGreater(state.getGovOpinion(0, 1), OPINION.baseline, "still above neutral");
state.tickHour();
this.assertApprox(state.getGovOpinion(0, 1), OPINION.baseline, 1e-9, "ten points, ten hours");
// Below neutral drifts up at the same constant speed.
state._shiftGovOpinion(0, 1, -3 * 0.005);
for (let i = 0; i < 3; i++) state.tickHour();
this.assertApprox(state.getGovOpinion(0, 1), OPINION.baseline, 1e-9, "constant speed either way");
}
test_popularity_points_and_display_follow_the_logistic() {
const state = smallState();
// Neutral reads 50%, +100 points reads 75%, -100 reads 25%.
this.assertApprox(state.getCivPopularityDisplay(0), 0.5, 1e-9);
for (let eth = 0; eth < state.civilisations.length; eth++) {
state._shiftGovOpinion(eth, 0, 0.5);
}
this.assertApprox(state.getCivPopularity(0), 100, 1, "the own people are fully won over");
this.assertApprox(state.getCivPopularityDisplay(0), 0.75, 1e-6);
}
test_a_large_minority_cannot_be_expelled() {
const state = smallState();
this.assertLess(state.getCivEthnicShare(0, 1), 0.1, "the minority is small at first");
this.assertTrue(state.requestPolicy(0, POLICY_EXPEL, 1), "a small minority can be expelled");
const big = smallState();
for (const coords of big._territoryByCiv.get(0) || []) {
big._setTileEthnicFractions(coords, new Map([[0, 0.7], [1, 0.3]]));
}
this.assertGreater(big.getCivEthnicShare(0, 1), 0.1, "now a tenth of the country");
this.assertFalse(big.requestPolicy(0, POLICY_EXPEL, 1), "too many to drive out");
}
test_declaring_war_costs_the_aggressor_popularity() {
const state = smallState();
state._shiftGovOpinion(0, 0, 0.2);
const before = state.getGovOpinion(0, 0);
this.assertTrue(state.requestDeclareWar(0, 1));
this.assertLess(state.getGovOpinion(0, 0), before, "the aggressor's own people disapprove");
}
test_being_attacked_by_a_loathed_rival_rallies_the_people() {
const state = smallState();
// Civ 1's people already loathe civ 0 below the 15% threshold.
for (let eth = 0; eth < state.civilisations.length; eth++) {
state._shiftGovOpinion(eth, 0, -0.4);
}
const before = state.getGovOpinion(1, 1);
this.assertTrue(state.requestDeclareWar(0, 1));
this.assertGreater(state.getGovOpinion(1, 1), before, "the victim's people rally");
}
test_losing_and_killing_units_moves_popularity() {
const state = smallState();
const tile = state.landCells.find(
(coords) => !state.unitAt(coords) && !state.cityAt(coords)
);
this.assertNotNull(tile, "an empty land tile");
const attacker = state._spawnUnit(tile, 0, state.protoUnits[0]);
const defender = state._spawnUnit(tile, 1, state.protoUnits[0]);
defender.hp = 1;
const attackerBefore = state.getGovOpinion(0, 0);
const defenderBefore = state.getGovOpinion(1, 1);
state._resolveCombatRound();
this.assertFalse(state.units.includes(defender), "the defender was destroyed");
this.assertLess(state.getGovOpinion(1, 1), defenderBefore, "losing a unit hurts at home");
this.assertGreater(state.getGovOpinion(0, 0), attackerBefore, "the killer's people cheer");
}
test_taking_a_city_moves_popularity() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 1);
this.assertNotNull(city, "civ 1 has a city");
// Clear the garrison and walk a unit in.
for (const unit of state.unitsAt(city.coords)) state._destroyUnit(unit);
const conquerorBefore = state.getGovOpinion(0, 0);
const loserBefore = state.getGovOpinion(1, 1);
state._captureCity(city, 0);
this.assertGreater(state.getGovOpinion(0, 0), conquerorBefore, "the conqueror's people cheer");
this.assertLess(state.getGovOpinion(1, 1), loserBefore, "the losers despair");
}
}
// A pair of neighbouring tiles where `from` is owned by `fromCiv` and the
+10 -8
View File
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { buildRoadNetwork, greedySpanner, tileRoadCost } from "../shared/roads.js";
import { ROADS, TERRAIN_TILES } from "../shared/data.js";
import { ROADS, TERRAIN_TILES, transportImprovement } from "../shared/data.js";
import { key, parseKey } from "../shared/hex.js";
export class RoadsTest extends TestCase {
@@ -19,26 +19,28 @@ export class RoadsTest extends TestCase {
this.assertEqual(tileRoadCost(sea, true), 0, "a city tile is free");
}
test_cities_start_with_a_road_and_are_connected_by_the_network() {
test_cities_are_founded_on_a_railway_and_connected_by_the_network() {
const state = smallState(["france", "britain", "poland"], 12345, { citiesPerCiv: 3 });
const railwayName = transportImprovement("railway").name;
const cityKeys = state.cities.map((city) => key(city.coords.x, city.coords.y));
for (const city of state.cities) {
this.assertTrue(
city.improvements.includes(ROADS.improvementName),
"a city is founded with a road"
city.improvements.includes(railwayName),
"a city is automatically improved with a railway"
);
}
for (const cityKey of cityKeys) this.assertTrue(state.roads.has(cityKey));
this.assertGreater(state.roads.size, cityKeys.length, "roads also cross the land");
for (const cityKey of cityKeys) this.assertTrue(state.railways.has(cityKey));
this.assertGreater(state.roads.size, 0, "roads also cross the land");
// Following roads from the first city must reach every other city.
// Following the network from the first city must reach every other city.
const passable = (k) => state.roads.has(k) || state.railways.has(k);
const seen = new Set([cityKeys[0]]);
const queue = [parseKey(cityKeys[0])];
while (queue.length > 0) {
const coords = queue.pop();
for (const neighbour of state.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (!state.roads.has(nk) || seen.has(nk)) continue;
if (!passable(nk) || seen.has(nk)) continue;
seen.add(nk);
queue.push(neighbour);
}
+295
View File
@@ -0,0 +1,295 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import { TRENCH } from "../shared/data/combat.js";
const BIG_BUDGET = 1.0e15;
function emptyLandPair(state) {
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
}
}
return null;
}
function ownLand(state, civ = 0) {
for (const cell of state.landCells) {
if (state.civAt(cell) !== civ) continue;
if (state.unitAt(cell) || state.cityAt(cell)) continue;
return cell;
}
return null;
}
export class TrenchTest extends TestCase {
test_infantry_dig_in_on_their_own_land() {
const state = smallState();
const coords = ownLand(state);
this.assertNotNull(coords, "the nation holds land");
const infantry = state._spawnUnit(coords, 0, state.protoUnits[0]);
this.assertTrue(state.requestDigTrench([infantry.id]));
this.assertTrue(state.trenches.has(key(coords.x, coords.y)));
this.assertFalse(state.requestDigTrench([infantry.id]), "already dug in");
}
test_only_infantry_can_dig() {
const state = smallState();
const coords = ownLand(state);
const artillery = state._spawnUnit(coords, 0, state.protoUnits[2]);
this.assertFalse(state.requestDigTrench([artillery.id]));
this.assertFalse(state.trenches.has(key(coords.x, coords.y)));
}
test_trenches_multiply_defense_and_cut_gdp() {
const state = smallState();
const coords = ownLand(state);
const k = key(coords.x, coords.y);
const before = state.getTileGdpPerCapita(coords);
state.trenches.add(k);
state._gdpPerCapitaCache.delete(k);
this.assertApprox(
state.getTileGdpPerCapita(coords),
before * TRENCH.gdpMultiplier,
1e-6,
"a trench costs the tile a fifth of its GDP per capita"
);
// Infantry attackers are blunted partly; artillery far more.
const infantryProto = state.protoUnits[0];
const artilleryProto = state.protoUnits[2];
this.assertApprox(
state._tileFortificationMultiplier(coords, infantryProto),
TRENCH.meleeDefenseMultiplier,
1e-9
);
this.assertApprox(
state._tileFortificationMultiplier(coords, artilleryProto),
TRENCH.artilleryDefenseMultiplier,
1e-9
);
}
}
export class CaptureTest extends TestCase {
test_infantry_captures_unaccompanied_artillery() {
const state = smallState();
const pair = emptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestMove(infantry.id, pair.a));
state.advanceMovement(1000);
this.assertEqual(artillery.civ, 1, "the battery changed hands");
}
test_infantry_beside_the_artillery_protects_it() {
const state = smallState();
const pair = emptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
state._spawnUnit(pair.a, 0, state.protoUnits[0]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestMove(infantry.id, pair.a));
state.advanceMovement(1000);
this.assertEqual(artillery.civ, 0, "the artillery is not captured while sheltered");
}
test_walking_onto_a_radar_tower_captures_it() {
const state = smallState();
const pair = emptyLandPair(state);
const k = key(pair.a.x, pair.a.y);
state.tileImprovements.set(k, "radar_tower");
state.tileImprovementOwner.set(k, 0);
state.tileImprovementHp.set(k, 500);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestMove(infantry.id, pair.a));
state.advanceMovement(1000);
this.assertEqual(state.tileImprovementOwner.get(k), 1, "the radar tower changed hands");
this.assertTrue(state.tileImprovements.has(k), "the tower is captured, not destroyed");
}
test_capturing_a_city_takes_its_aircraft() {
const state = smallState();
const city = cityOf(state, 1);
const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
city.buildings[airport] = 1;
const jet = state._spawnUnit(
city.coords,
1,
state.protoUnits.find((u) => u.id === "jet_fighter"),
city.id
);
state._captureCity(city, 0);
this.assertEqual(jet.civ, 0, "the aircraft is captured with the city");
this.assertEqual(jet.homeCityId, city.id);
}
}
export class TileImprovementTest extends TestCase {
test_a_radar_tower_takes_time_to_build() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const coords = ownLand(state);
const k = key(coords.x, coords.y);
this.assertTrue(state.requestBuildTileImprovement(0, coords, "radar_tower"));
this.assertFalse(state.tileImprovements.has(k), "not raised instantly");
for (let i = 0; i < 100000 && !state.tileImprovements.has(k); i++) state.advanceHour();
this.assertEqual(state.tileImprovements.get(k), "radar_tower", "the work completes");
this.assertEqual(state.tileImprovementOwner.get(k), 0);
}
test_a_coastal_cannon_needs_the_sea() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const inland = ownLand(state);
this.assertFalse(
state.requestBuildTileImprovement(0, inland, "coastal_cannon"),
"an inland tile cannot host a coastal cannon"
);
}
test_improvements_cost_an_hourly_maintenance() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const coords = ownLand(state);
const k = key(coords.x, coords.y);
const before = state.getTileImprovementUpkeep(0);
state.tileImprovements.set(k, "fortifications");
state.tileImprovementOwner.set(k, 0);
state.tileImprovementHp.set(k, 2000);
this.assertGreater(state.getTileImprovementUpkeep(0), before, "forts carry upkeep");
}
test_fortifications_hold_permanent_control() {
const state = smallState();
const city = cityOf(state, 0);
// A tile right beside the city: without the fort it would fall with it.
const coords = state.topology
.neighbours(city.coords.x, city.coords.y)
.find((n) => state._isLand(n) && state.civAt(n) === 0 && !state.unitAt(n) && !state.cityAt(n));
this.assertNotNull(coords, "the city borders a tile of its own land");
const k = key(coords.x, coords.y);
state.tileImprovements.set(k, "fortifications");
state.tileImprovementOwner.set(k, 0);
state.tileImprovementHp.set(k, 2000);
state._captureCity(city, 1);
this.assertEqual(state.civAt(coords), 0, "the fort's area of control never changes hands");
}
test_a_coastal_cannon_fires_on_a_warship() {
const state = smallState();
const coastal = state.landCells.find((cell) =>
state.civAt(cell) === 0 &&
state.topology.neighbours(cell.x, cell.y).some(
(n) => state.tiles[key(n.x, n.y)].terrainClass === "Sea"
)
);
this.assertNotNull(coastal, "the nation holds a coastal tile");
const sea = state.topology.neighbours(coastal.x, coastal.y).find(
(n) => state.tiles[key(n.x, n.y)].terrainClass === "Sea"
);
const k = key(coastal.x, coastal.y);
state.tileImprovements.set(k, "coastal_cannon");
state.tileImprovementOwner.set(k, 0);
state.tileImprovementHp.set(k, 800);
const warship = state._spawnUnit(
sea,
1,
state.protoUnits.find((u) => u.id === "battleship")
);
const hp = warship.hp;
state._tickCoastalCannons();
this.assertLess(warship.hp, hp, "the cannon hits the warship");
this.assertTrue(state.tileImprovements.has(k), "the cannon survives (only fires)");
}
}
export class TransportTest extends TestCase {
coastalSetup(state) {
for (const cell of state.landCells) {
if (state.civAt(cell) !== 0) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (state.tiles[key(neighbour.x, neighbour.y)].terrainClass !== "Sea") continue;
if (state.unitAt(cell) || state.unitAt(neighbour)) continue;
return { land: { x: cell.x, y: cell.y }, sea: { x: neighbour.x, y: neighbour.y } };
}
}
return null;
}
test_infantry_board_a_launch_and_are_carried() {
const state = smallState();
const spot = this.coastalSetup(state);
this.assertNotNull(spot, "the nation has a coastal tile");
const launchProto = state.protoUnits.find((u) => u.id === "launch");
const launch = state._spawnUnit(spot.sea, 0, launchProto);
const infantry = state._spawnUnit(spot.land, 0, state.protoUnits[0]);
this.assertTrue(state.requestEmbark([infantry.id], launch.id), "boarding begins");
for (let i = 0; i < 24; i++) state.advanceHour();
this.assertNull(state.findUnit(infantry.id), "the infantry left the map");
this.assertEqual(launch.cargo.length, 1, "the infantry is cargo");
}
test_a_loaded_launch_disembarks_its_stack() {
const state = smallState();
const spot = this.coastalSetup(state);
const launchProto = state.protoUnits.find((u) => u.id === "launch");
const launch = state._spawnUnit(spot.sea, 0, launchProto);
const infantry = state._spawnUnit(spot.land, 0, state.protoUnits[0]);
state.requestEmbark([infantry.id], launch.id);
for (let i = 0; i < 24; i++) state.advanceHour();
this.assertTrue(state.requestDisembark([launch.id], spot.land), "the troops go ashore");
this.assertEqual(launch.cargo.length, 0, "the launch is empty");
this.assertGreater(state.unitsAt(spot.land).length, 0, "a unit stands on the shore");
}
}
export class ZoneOfControlTest extends TestCase {
// Two neighbouring, unclaimed land tiles, so terrain and ownership never
// interfere with the zone-of-control check under test.
neutralPair(state) {
for (const cell of state.landCells) {
if (state.civAt(cell) !== 0) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour) || state.civAt(neighbour) !== 0) continue;
if (state.unitAt(cell) || state.unitAt(neighbour)) continue;
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
}
}
return null;
}
test_a_unit_cannot_cross_between_two_denied_tiles() {
const state = smallState();
const pair = this.neutralPair(state);
this.assertNotNull(pair, "the map has two adjacent neutral land tiles");
const mover = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
// The hostile is only denied by the artificial zone, not by occupancy.
const zoc = new Set([key(pair.a.x, pair.a.y), key(pair.b.x, pair.b.y)]);
this.assertFalse(
state._canUnitEnter(mover, pair.b, false, -1, null, zoc, pair.a),
"denied to denied is blocked"
);
this.assertTrue(
state._canUnitEnter(mover, pair.b, false, -1, null, new Set([key(pair.b.x, pair.b.y)]), pair.a),
"entering the ring from outside is allowed"
);
this.assertTrue(
state._canUnitEnter(mover, pair.b, true, -1, null, zoc, pair.a),
"a deliberate attack on the goal is allowed"
);
}
test_a_hostile_unit_denies_its_tile_and_neighbours() {
const state = smallState();
const pair = emptyLandPair(state);
state._spawnUnit(pair.a, 1, state.protoUnits[0]);
const zoc = state._zoneOfControl(0);
this.assertTrue(zoc.has(key(pair.a.x, pair.a.y)), "the hostile's tile is denied");
for (const neighbour of state.topology.neighbours(pair.a.x, pair.a.y)) {
this.assertTrue(zoc.has(key(neighbour.x, neighbour.y)), "each neighbour is denied");
}
}
}