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.
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||