First commit, done on M6 from the PLAN.MD
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/* Decision cards: documents in the manila folder, answered with rubber stamps */
|
||||
|
||||
.inbox__body.is-card {
|
||||
padding: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
padding: 16px 20px 12px;
|
||||
background-color: var(--paper);
|
||||
background-image: var(--paper-grain);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 2px solid var(--ink);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.card__kind { font-weight: 700; color: var(--ink); }
|
||||
.card__date { font-family: var(--font-num); letter-spacing: 0.04em; text-transform: none; }
|
||||
|
||||
.card__from { display: flex; align-items: center; gap: 10px; margin: 10px 0 6px; }
|
||||
.card__from b { display: block; font-size: 13px; font-weight: 400; }
|
||||
.card__from small { display: block; font-size: 11px; opacity: 0.75; }
|
||||
|
||||
.seal {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--ink);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.04em;
|
||||
flex: none;
|
||||
box-shadow: inset 0 0 0 2px var(--paper), inset 0 0 0 3px var(--ink);
|
||||
}
|
||||
|
||||
.card__title { margin: 4px 0 6px; font-family: var(--font-head); font-weight: 400; font-size: 24px; line-height: 1.1; }
|
||||
.card__body p { margin: 0 0 8px; font-size: 14px; line-height: 1.45; }
|
||||
|
||||
.card__choices { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 14px; }
|
||||
.card__keys { margin: 8px 0 0; text-align: center; font-size: 10px; opacity: 0.6; }
|
||||
|
||||
/* Document types */
|
||||
.card--telegram {
|
||||
background-color: #efe0a2;
|
||||
font-family: var(--font-num);
|
||||
border-top: 6px solid #c9a93a;
|
||||
border-bottom: 6px solid #c9a93a;
|
||||
}
|
||||
.card--telegram .card__head { border-bottom: 2px dashed var(--ink); }
|
||||
.card--telegram .card__body p { text-transform: uppercase; letter-spacing: 0.04em; font-size: 13px; }
|
||||
.card--telegram .card__title { font-family: var(--font-num); font-weight: 700; font-size: 20px; text-transform: uppercase; }
|
||||
|
||||
.card--petition {
|
||||
background-image:
|
||||
var(--paper-grain),
|
||||
repeating-linear-gradient(transparent 0 21px, rgba(60, 105, 140, 0.28) 21px 22px);
|
||||
background-position: 0 0, 0 4px;
|
||||
}
|
||||
.card--petition .card__kind::after { content: " ✎"; }
|
||||
|
||||
.card--report { background-color: #e4e6df; border-top: 8px solid var(--ink); }
|
||||
.card--report .card__head { border-bottom: 1px solid var(--ink); }
|
||||
|
||||
.card--letter { background-color: #f3e8cb; font-family: var(--font-head); }
|
||||
.card--letter .card__body p { font-size: 15px; }
|
||||
.card--letter .seal { background: #8d2b1f; color: #f6e7c8; border-color: #5e1a12; box-shadow: inset 0 0 0 2px #8d2b1f, inset 0 0 0 3px #f6e7c8; }
|
||||
|
||||
/* Stamp buttons */
|
||||
.stamp-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 84px;
|
||||
padding: 10px 10px 8px;
|
||||
font-family: var(--font-type);
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border: 2px solid rgba(43, 36, 24, 0.55);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stamp-btn__ink {
|
||||
padding: 1px 8px;
|
||||
border: 2px solid currentColor;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
transform: rotate(-4deg);
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.stamp-btn--yes .stamp-btn__ink { color: var(--ink-green); }
|
||||
.stamp-btn--no .stamp-btn__ink { color: var(--ink-red); }
|
||||
.stamp-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.6); }
|
||||
.stamp-btn:hover:not(:disabled) .stamp-btn__ink { opacity: 1; transform: rotate(-4deg) scale(1.08); }
|
||||
.stamp-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||
.stamp-btn:disabled { cursor: default; opacity: 0.5; }
|
||||
|
||||
/* The big stamp that lands on the document */
|
||||
.card__stamp {
|
||||
position: absolute;
|
||||
top: 46%;
|
||||
left: 50%;
|
||||
padding: 4px 22px;
|
||||
border: 6px solid currentColor;
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-type);
|
||||
font-size: 44px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: multiply;
|
||||
transform: translate(-50%, -50%) rotate(-12deg);
|
||||
animation: stamp-in 0.3s cubic-bezier(0.2, 0.9, 0.3, 1.2) both;
|
||||
}
|
||||
.card__stamp--yes { color: var(--ink-green); }
|
||||
.card__stamp--no { color: var(--ink-red); }
|
||||
|
||||
@keyframes stamp-in {
|
||||
from { opacity: 0; transform: translate(-50%, -50%) rotate(-12deg) scale(2.6); }
|
||||
to { opacity: 0.9; transform: translate(-50%, -50%) rotate(-12deg) scale(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card__stamp { animation: none; opacity: 0.9; }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
@font-face {
|
||||
font-family: "Special Elite";
|
||||
src: url("../fonts/SpecialElite-Regular.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IM Fell English";
|
||||
src: url("../fonts/IMFeENrm28P.ttf") format("truetype");
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IM Fell English";
|
||||
src: url("../fonts/IMFeENit28P.ttf") format("truetype");
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Courier Prime";
|
||||
src: url("../fonts/CourierPrime-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Courier Prime";
|
||||
src: url("../fonts/CourierPrime-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-type: "Special Elite", "Courier New", monospace;
|
||||
--font-head: "IM Fell English", Georgia, serif;
|
||||
--font-num: "Courier Prime", "Courier New", monospace;
|
||||
|
||||
--ink: #1c2a44;
|
||||
--ink-red: #8d2b1f;
|
||||
--ink-green: #2c5a3a;
|
||||
--text: #2b2418;
|
||||
--paper: #efe4c8;
|
||||
--news-paper: #e2dccb;
|
||||
|
||||
--paper-grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .38 0 0 0 0 .30 0 0 0 0 .18 .5 0 0 0 -.08'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
||||
--leather-grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='l'><feTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .9 0 0 0 -.25'/></filter><rect width='100%' height='100%' filter='url(%23l)'/></svg>");
|
||||
--wood-grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='700' height='260'><filter id='w'><feTurbulence type='fractalNoise' baseFrequency='0.004 0.45' numOctaves='4' seed='4'/><feColorMatrix values='0 0 0 0 .10 0 0 0 0 .05 0 0 0 0 .02 1.3 0 0 0 -.45'/></filter><rect width='100%' height='100%' filter='url(%23w)'/></svg>");
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #4b2f1b;
|
||||
background-image:
|
||||
var(--wood-grain),
|
||||
repeating-linear-gradient(0deg, transparent 0 258px, rgba(0, 0, 0, 0.5) 258px 261px, rgba(255, 215, 160, 0.07) 261px 263px),
|
||||
linear-gradient(90deg, #3d2615, #5d3b22 35%, #47301b 70%, #603d22);
|
||||
color: var(--text);
|
||||
font-family: var(--font-type);
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
#desk {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: inset 0 0 180px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
/* Leather desk pad with a stitched border */
|
||||
#pad {
|
||||
position: relative;
|
||||
width: min(1320px, 100%);
|
||||
height: calc(100vh - 40px);
|
||||
min-height: 640px;
|
||||
padding: 30px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 1.1fr) minmax(300px, 1fr) minmax(300px, 0.9fr);
|
||||
gap: 24px;
|
||||
background-color: #26322c;
|
||||
background-image:
|
||||
var(--leather-grain),
|
||||
radial-gradient(ellipse at 30% 15%, rgba(255, 255, 255, 0.08), transparent 60%);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(0, 0, 0, 0.45),
|
||||
inset 0 0 80px rgba(0, 0, 0, 0.6),
|
||||
0 20px 44px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
#pad::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 12px;
|
||||
border: 2px dashed rgba(224, 196, 140, 0.4);
|
||||
border-radius: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#side {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#pad > * { min-height: 0; }
|
||||
@@ -0,0 +1,161 @@
|
||||
/* Urgent telegrams and the end-of-game front page */
|
||||
|
||||
/* ---- Urgent telegram: slides in over the desk ---- */
|
||||
.alerts {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
z-index: 30;
|
||||
width: min(560px, 90%);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.alerts[hidden] { display: none; }
|
||||
|
||||
.telegram {
|
||||
padding: 12px 18px 14px;
|
||||
background: #efe0a2 var(--paper-grain);
|
||||
border-top: 7px solid #c9a93a;
|
||||
border-bottom: 7px solid #c9a93a;
|
||||
font-family: var(--font-num);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.7);
|
||||
transform: rotate(-0.6deg);
|
||||
animation: telegram-in 0.45s cubic-bezier(0.2, 0.9, 0.3, 1.1) both;
|
||||
}
|
||||
.telegram__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 2px dashed var(--ink);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-red);
|
||||
}
|
||||
.telegram h3 { margin: 8px 0 4px; font-family: var(--font-num); font-weight: 700; font-size: 19px; text-transform: uppercase; }
|
||||
.telegram p { margin: 0 0 6px; font-size: 13px; line-height: 1.5; letter-spacing: 0.04em; }
|
||||
.telegram .brass { margin-top: 6px; }
|
||||
|
||||
@keyframes telegram-in {
|
||||
from { opacity: 0; transform: translateY(-60px) rotate(-3deg); }
|
||||
to { opacity: 1; transform: translateY(0) rotate(-0.6deg); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .telegram { animation: none; } }
|
||||
|
||||
/* ---- Profile question (first thing in the folder) ---- */
|
||||
.profile__ask { font-weight: 700; margin-top: 14px; }
|
||||
.profile__choices { display: grid; gap: 8px; margin: 10px 0 6px; }
|
||||
.profile__choices .brass--wide { margin-top: 0; }
|
||||
.profile__note { margin: 8px 0 0; font-size: 11px; opacity: 0.7; }
|
||||
|
||||
/* ---- End of game ---- */
|
||||
#endgame {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
#endgame[hidden] { display: none; }
|
||||
|
||||
#pad.is-endgame #dashboard,
|
||||
#pad.is-endgame #folder,
|
||||
#pad.is-endgame #side,
|
||||
#pad.is-endgame #ledger { display: none; }
|
||||
|
||||
.end {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 22px 12px;
|
||||
overflow-y: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.end__banner { text-align: center; padding: 4px 0 10px; }
|
||||
.end__banner h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-weight: 400;
|
||||
font-size: 40px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.end--lost .end__banner h1 { color: var(--ink-red); }
|
||||
.end__banner p { margin: 4px 0 0; font-size: 16px; font-style: italic; }
|
||||
|
||||
.end__grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(250px, 1fr) minmax(300px, 1.3fr) minmax(250px, 1fr);
|
||||
gap: 22px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--text);
|
||||
}
|
||||
.end__grid h3 {
|
||||
margin: 0 0 6px;
|
||||
padding-bottom: 2px;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid rgba(43, 36, 24, 0.5);
|
||||
}
|
||||
|
||||
.end__verdict { display: flex; flex-direction: column; min-height: 0; }
|
||||
.end__grade {
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 4px 0 6px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.end__letter {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
border: 5px double var(--ink);
|
||||
border-radius: 50%;
|
||||
font-family: var(--font-head);
|
||||
font-size: 50px;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
transform: rotate(-6deg);
|
||||
}
|
||||
.end__grade[data-grade="S"] .end__letter,
|
||||
.end__grade[data-grade="A"] .end__letter { color: var(--ink-green); border-color: var(--ink-green); }
|
||||
.end__grade[data-grade="D"] .end__letter,
|
||||
.end__grade[data-grade="F"] .end__letter { color: var(--ink-red); border-color: var(--ink-red); }
|
||||
.end__title { margin: 4px 0; font-size: 24px; line-height: 1.1; font-style: italic; }
|
||||
.end__title::before { content: "« "; }
|
||||
.end__title::after { content: " »"; }
|
||||
.end__text { margin: 0 0 8px; font-size: 15px; line-height: 1.4; }
|
||||
.end__score { margin: auto 0 0; font-family: var(--font-num); font-size: 12px; opacity: 0.85; }
|
||||
|
||||
.end__charts { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-content: start; }
|
||||
.end__chart { margin: 0; }
|
||||
.end__chart figcaption { display: flex; align-items: baseline; gap: 6px; font-size: 13px; white-space: nowrap; }
|
||||
.end__chart figcaption span { overflow: hidden; text-overflow: ellipsis; }
|
||||
.end__chart figcaption b { font-family: var(--font-num); }
|
||||
.end__chart figcaption small { margin-left: auto; font-size: 10px; opacity: 0.7; }
|
||||
.chart { display: block; height: 96px; background: rgba(255, 255, 255, 0.3); border: 1px solid rgba(60, 105, 140, 0.4); }
|
||||
|
||||
.end__decisions ol { margin: 0; padding: 0; list-style: none; counter-reset: d; }
|
||||
.end__decisions li { counter-increment: d; position: relative; padding: 0 0 8px 26px; font-size: 14px; line-height: 1.25; }
|
||||
.end__decisions li::before {
|
||||
content: counter(d);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 22px;
|
||||
color: var(--ink-red);
|
||||
}
|
||||
.end__decisions li b { display: block; font-weight: 400; font-size: 15px; }
|
||||
.end__when { display: block; font-size: 11px; opacity: 0.7; }
|
||||
.end__chose { display: block; font-size: 12px; font-style: italic; }
|
||||
|
||||
.end__foot { padding-top: 8px; text-align: center; }
|
||||
.end__foot .brass--wide { margin-top: 0; }
|
||||
@@ -0,0 +1,145 @@
|
||||
/* Quarterly ledger: a two-page accounting book over the centre and right columns */
|
||||
|
||||
.ledger {
|
||||
grid-column: 2 / 4;
|
||||
grid-row: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.ledger[hidden] { display: none; }
|
||||
|
||||
#pad.is-ledger #folder,
|
||||
#pad.is-ledger #side { display: none; }
|
||||
|
||||
.ledger__book {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 10px;
|
||||
background: #1d3a2b;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.4), 0 12px 24px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.ledger__page {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
padding: 14px 18px 12px 26px;
|
||||
overflow-y: auto;
|
||||
color: var(--text);
|
||||
background-color: #f1e9cf;
|
||||
background-image:
|
||||
var(--paper-grain),
|
||||
linear-gradient(90deg, transparent 18px, rgba(160, 50, 40, 0.5) 18px 19px, transparent 19px);
|
||||
}
|
||||
.ledger__page--left { border-radius: 4px 0 0 4px; box-shadow: inset -14px 0 18px -10px rgba(60, 40, 10, 0.5); }
|
||||
.ledger__page--right { border-radius: 0 4px 4px 0; box-shadow: inset 14px 0 18px -10px rgba(60, 40, 10, 0.5); }
|
||||
|
||||
.ledger__page h2 { margin: 0; font-family: var(--font-head); font-weight: 400; font-size: 21px; letter-spacing: 0.03em; }
|
||||
.ledger__page h3 {
|
||||
margin: 10px 0 4px;
|
||||
font-family: var(--font-head);
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid rgba(43, 36, 24, 0.5);
|
||||
}
|
||||
.ledger__sub { margin: 2px 0 6px; font-size: 11px; line-height: 1.3; opacity: 0.8; }
|
||||
|
||||
/* Rows sit on the green ruled lines of the book (28px pitch) */
|
||||
.ledger__rows {
|
||||
background: repeating-linear-gradient(transparent 0 27px, rgba(50, 120, 80, 0.38) 27px 28px);
|
||||
}
|
||||
|
||||
.lrow {
|
||||
display: grid;
|
||||
grid-template-columns: 124px minmax(60px, 1fr) 54px 18px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
}
|
||||
.lrow--tax { grid-template-columns: 112px minmax(50px, 1fr) 54px 74px; }
|
||||
|
||||
.lrow__name {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.lrow__ctl { position: relative; display: block; height: 20px; }
|
||||
.lrow__ctl input { position: absolute; inset: 0; width: 100%; margin: 0; }
|
||||
.lrow__tick {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
width: 2px;
|
||||
height: 18px;
|
||||
background: var(--ink-red);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
.lrow__val { white-space: nowrap; font-family: var(--font-num); font-weight: 700; font-size: 13px; text-align: right; color: var(--ink); }
|
||||
.lrow.is-changed .lrow__val { color: var(--ink-red); }
|
||||
.lrow__q { text-align: center; font-size: 11px; cursor: help; }
|
||||
.lrow__q.up { color: var(--ink-green); }
|
||||
.lrow__q.down { color: var(--ink-red); }
|
||||
.lrow__q.flat { opacity: 0.5; }
|
||||
.lrow__yield { font-size: 11px; text-align: right; opacity: 0.8; }
|
||||
|
||||
.ledger__total {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
border-top: 2px solid var(--ink);
|
||||
padding-top: 3px;
|
||||
}
|
||||
.ledger__total b { font-family: var(--font-num); }
|
||||
|
||||
.sum { display: grid; grid-template-columns: 1fr auto; gap: 1px 10px; margin: 0; font-size: 13px; }
|
||||
.sum dd { margin: 0; text-align: right; font-family: var(--font-num); font-weight: 700; }
|
||||
.sum small { display: block; font-weight: 400; font-size: 11px; }
|
||||
.sum dt:nth-of-type(4), .sum dd:nth-of-type(4) { border-top: 1px solid var(--ink); }
|
||||
.sum__balance { font-weight: 700; }
|
||||
.sum__balance.good { color: var(--ink-green); }
|
||||
.sum__balance.bad { color: var(--ink-red); }
|
||||
|
||||
.proj { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.proj th { font-weight: 400; text-align: left; padding: 1px 0; }
|
||||
.proj thead th { text-align: right; font-size: 11px; opacity: 0.8; }
|
||||
.proj td { white-space: nowrap; text-align: right; font-family: var(--font-num); padding: 1px 0 1px 6px; }
|
||||
.proj tbody tr:nth-child(odd) { background: rgba(50, 120, 80, 0.08); }
|
||||
|
||||
.ledger__actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 10px; }
|
||||
.ledger__actions .brass { white-space: nowrap; padding: 5px 12px; font-size: 13px; }
|
||||
.ledger__actions .brass--wide { margin-top: 0; }
|
||||
.ledger__error { color: var(--ink-red); margin: 6px 0 0; font-size: 12px; }
|
||||
.ledger__error[hidden] { display: none; }
|
||||
|
||||
/* Range sliders: an ink rule with a brass knob */
|
||||
.ledger input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ledger input[type="range"]::-webkit-slider-runnable-track { height: 4px; background: var(--ink); border-radius: 2px; }
|
||||
.ledger input[type="range"]::-moz-range-track { height: 4px; background: var(--ink); border-radius: 2px; }
|
||||
.ledger input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -5px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #6e5316;
|
||||
background: radial-gradient(circle at 35% 30%, #f1d98c, #b98e32);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.ledger input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #6e5316;
|
||||
background: radial-gradient(circle at 35% 30%, #f1d98c, #b98e32);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.ledger input[type="range"]:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
|
||||
@@ -0,0 +1,126 @@
|
||||
/* Main menu and pause menu: a "Top secret" folder in the middle of the desk */
|
||||
|
||||
#menu {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 0;
|
||||
}
|
||||
#menu[hidden] { display: none; }
|
||||
|
||||
/* While the menu is open it replaces everything else on the pad. */
|
||||
#pad.is-menu > :not(#menu) { display: none !important; }
|
||||
|
||||
.cal__menu { display: block; width: 100%; margin-top: 8px; }
|
||||
|
||||
.menu__folder {
|
||||
position: relative;
|
||||
width: min(580px, 94%);
|
||||
max-height: 100%;
|
||||
padding: 42px 22px 22px;
|
||||
overflow-y: auto;
|
||||
background: #d4b77a var(--paper-grain);
|
||||
box-shadow:
|
||||
inset 0 0 40px rgba(110, 75, 25, 0.35),
|
||||
0 2px 2px rgba(0, 0, 0, 0.35),
|
||||
0 18px 34px rgba(0, 0, 0, 0.6);
|
||||
transform: rotate(-0.4deg);
|
||||
}
|
||||
|
||||
.menu__stamp {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 22px;
|
||||
font-size: 15px;
|
||||
padding: 3px 12px;
|
||||
transform: rotate(7deg);
|
||||
}
|
||||
|
||||
.menu__sheet {
|
||||
padding: 20px 24px 18px;
|
||||
background: var(--paper) var(--paper-grain);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.menu__sheet h1,
|
||||
.menu__sheet h3 { margin: 0 0 6px; font-family: var(--font-head); font-weight: 400; }
|
||||
.menu__title { font-size: 46px; line-height: 1; letter-spacing: 0.02em; }
|
||||
.menu__sheet h3 { font-size: 24px; }
|
||||
.menu__sheet p { margin: 0 0 10px; font-size: 14px; line-height: 1.4; }
|
||||
.menu__sub { opacity: 0.85; }
|
||||
|
||||
.menu__list { display: grid; gap: 8px; margin: 14px 0 4px; }
|
||||
.menu__list .brass--wide,
|
||||
.menu__row .brass--wide { margin-top: 0; width: 100%; }
|
||||
.brass small { display: block; font-size: 11px; font-weight: 400; opacity: 0.8; }
|
||||
|
||||
.menu__row { display: flex; gap: 10px; margin: 10px 0 4px; }
|
||||
.menu__row .brass { flex: 1; }
|
||||
.menu__foot { margin-top: 14px; display: grid; }
|
||||
.menu__foot .brass--wide { margin-top: 0; }
|
||||
|
||||
.menu__field { display: block; margin: 12px 0 0; padding: 0; border: 0; font-size: 13px; }
|
||||
.menu__field input[type="text"],
|
||||
.menu__field select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 7px 8px;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(43, 36, 24, 0.55);
|
||||
}
|
||||
.menu__field legend { padding: 0; margin-bottom: 4px; }
|
||||
.menu__radios label { display: block; padding: 4px 0; cursor: pointer; }
|
||||
.menu__radios input { margin-right: 8px; accent-color: var(--ink); }
|
||||
.menu__check { display: flex; gap: 10px; align-items: flex-start; cursor: pointer; }
|
||||
.menu__check input { margin-top: 4px; accent-color: var(--ink); }
|
||||
.menu__check small { display: block; font-size: 11px; opacity: 0.75; }
|
||||
|
||||
.menu__text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(43, 36, 24, 0.55);
|
||||
resize: vertical;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.menu__msg { margin: 8px 0 0; padding: 4px 8px; font-size: 13px; border-left: 4px solid var(--ink-green); background: rgba(44, 90, 58, 0.1); }
|
||||
.menu__msg--error { border-left-color: var(--ink-red); background: rgba(141, 43, 31, 0.1); color: var(--ink-red); }
|
||||
|
||||
/* Save slots */
|
||||
.menu__slots { margin: 10px 0 0; padding: 0; list-style: none; }
|
||||
.menu__slot {
|
||||
display: grid;
|
||||
grid-template-columns: 112px 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px dotted rgba(43, 36, 24, 0.45);
|
||||
font-size: 13px;
|
||||
}
|
||||
.menu__slotname { font-family: var(--font-head); font-size: 17px; }
|
||||
.menu__slotinfo small { display: block; font-size: 11px; opacity: 0.7; }
|
||||
.menu__slotinfo em { opacity: 0.6; }
|
||||
.menu__slotbtns { display: flex; gap: 6px; }
|
||||
.menu__slotbtns .brass { padding: 4px 10px; font-size: 12px; white-space: nowrap; }
|
||||
.brass--armed { background: linear-gradient(#d9826f, #a8422f); color: #fff; border-color: #5e1a12; }
|
||||
|
||||
.menu__file { display: grid; place-items: center; margin: 0; cursor: pointer; }
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.menu__folder { animation: folder-in 0.3s ease-out both; }
|
||||
}
|
||||
@keyframes folder-in {
|
||||
from { opacity: 0; transform: translateY(14px) rotate(-1.5deg); }
|
||||
to { opacity: 1; transform: translateY(0) rotate(-0.4deg); }
|
||||
}
|
||||
.menu__stamp { max-width: none; white-space: nowrap; }
|
||||
@@ -0,0 +1,257 @@
|
||||
/* Paper sheets on the desk */
|
||||
|
||||
.paper {
|
||||
position: relative;
|
||||
padding: 16px 18px;
|
||||
background-color: var(--paper);
|
||||
background-image: var(--paper-grain);
|
||||
color: var(--text);
|
||||
transform: rotate(var(--rot, 0deg));
|
||||
box-shadow:
|
||||
inset 0 0 28px rgba(120, 90, 40, 0.22),
|
||||
0 1px 1px rgba(0, 0, 0, 0.35),
|
||||
0 10px 20px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.paper h2,
|
||||
.paper h3,
|
||||
.paper h4 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Graph paper (dashboard) */
|
||||
.paper--graph {
|
||||
background-image:
|
||||
var(--paper-grain),
|
||||
linear-gradient(rgba(60, 105, 140, 0.16) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(60, 105, 140, 0.16) 1px, transparent 1px);
|
||||
background-size: auto, 16px 16px, 16px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dash__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dash__head h2 {
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 2px solid var(--ink);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.dash__sub {
|
||||
margin: 10px 0 4px;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stamp {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border: 2px solid var(--ink-red);
|
||||
color: var(--ink-red);
|
||||
font-family: var(--font-type);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
transform: rotate(4deg);
|
||||
opacity: 0.8;
|
||||
max-width: 118px;
|
||||
font-size: 10px;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stamp--small { font-size: 10px; transform: rotate(-3deg); }
|
||||
|
||||
/* Gauges */
|
||||
.gauge {
|
||||
display: grid;
|
||||
grid-template-columns: 124px minmax(60px, 1fr) 28px 76px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 33px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.gauge__label { font-size: 13px; line-height: 1.1; }
|
||||
.gauge__band {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-red);
|
||||
}
|
||||
|
||||
.gauge__track {
|
||||
position: relative;
|
||||
height: 12px;
|
||||
border: 1px solid var(--ink);
|
||||
background: rgba(28, 42, 68, 0.07);
|
||||
}
|
||||
|
||||
.gauge__track i {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(28, 42, 68, 0.35);
|
||||
}
|
||||
.gauge__track i:nth-of-type(1) { left: 25%; }
|
||||
.gauge__track i:nth-of-type(2) { left: 50%; }
|
||||
.gauge__track i:nth-of-type(3) { left: 75%; }
|
||||
|
||||
.gauge__fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background:
|
||||
repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0 2px, transparent 2px 5px),
|
||||
var(--ink);
|
||||
transition: width 0.6s ease, background-color 0.6s;
|
||||
}
|
||||
|
||||
.gauge.is-low .gauge__fill { background-color: var(--ink-red); }
|
||||
.gauge.is-high .gauge__fill { background-color: var(--ink-green); }
|
||||
|
||||
.gauge__value {
|
||||
font-family: var(--font-num);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.gauge__spark { height: 24px; width: 76px; }
|
||||
|
||||
.spark { display: block; background: rgba(255, 255, 255, 0.25); border: 1px solid rgba(60, 105, 140, 0.4); }
|
||||
.spark__grid line { stroke: rgba(60, 105, 140, 0.25); stroke-width: 0.6; }
|
||||
.spark__line { fill: none; stroke: var(--ink); stroke-width: 1.4; stroke-linejoin: round; }
|
||||
.spark__dot { fill: var(--ink-red); }
|
||||
|
||||
/* Economic table */
|
||||
.econ { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.econ th { text-align: left; font-weight: 400; padding: 1px 0; }
|
||||
.econ__val { font-family: var(--font-num); font-weight: 700; text-align: right; padding-right: 8px; }
|
||||
.econ__trend { width: 18px; text-align: center; font-size: 11px; }
|
||||
.econ__trend.good { color: var(--ink-green); }
|
||||
.econ__trend.bad { color: var(--ink-red); }
|
||||
.econ__trend.flat { color: rgba(43, 36, 24, 0.5); }
|
||||
|
||||
/* Intelligence summary */
|
||||
.intel { list-style: none; margin: 0; padding: 0; font-size: 13px; }
|
||||
.intel li { display: flex; justify-content: space-between; padding: 1px 0; border-bottom: 1px dotted rgba(43, 36, 24, 0.4); }
|
||||
.intel b { font-family: var(--font-num); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.intel li[data-level="0"] b { color: var(--ink-green); }
|
||||
.intel li[data-level="1"] b { color: var(--ink); }
|
||||
.intel li[data-level="2"] b { color: #a4631b; }
|
||||
.intel li[data-level="3"] b,
|
||||
.intel li[data-level="4"] b { color: var(--ink-red); }
|
||||
|
||||
/* Manila folder (centre) */
|
||||
.paper--folder {
|
||||
background-color: #d4b77a;
|
||||
padding-top: 38px;
|
||||
box-shadow:
|
||||
inset 0 0 40px rgba(110, 75, 25, 0.35),
|
||||
0 1px 1px rgba(0, 0, 0, 0.35),
|
||||
0 10px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.inbox__tab {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 22px;
|
||||
padding: 6px 16px 4px;
|
||||
background: #c6a566;
|
||||
border-radius: 0 0 8px 8px;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: inset 0 -2px 4px rgba(90, 60, 20, 0.3);
|
||||
}
|
||||
|
||||
.inbox__body {
|
||||
background: var(--paper);
|
||||
background-image: var(--paper-grain);
|
||||
padding: 18px 20px;
|
||||
min-height: 160px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.inbox__body h3 { font-size: 20px; margin-bottom: 8px; }
|
||||
.inbox__idle { font-style: italic; opacity: 0.8; }
|
||||
|
||||
/* Desk calendar */
|
||||
.paper--calendar { padding: 0; text-align: center; }
|
||||
|
||||
.cal__head {
|
||||
padding: 5px 0 4px;
|
||||
background: var(--ink-red);
|
||||
color: #f6ead0;
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cal__body { padding: 8px 14px 12px; }
|
||||
.cal__day { font-family: var(--font-head); font-size: 54px; line-height: 1; }
|
||||
.cal__month { font-family: var(--font-head); font-size: 18px; text-transform: capitalize; }
|
||||
.cal__count { margin-top: 4px; font-size: 12px; opacity: 0.8; }
|
||||
|
||||
.cal__term {
|
||||
height: 6px;
|
||||
margin: 5px 0 8px;
|
||||
border: 1px solid var(--ink);
|
||||
background: rgba(28, 42, 68, 0.08);
|
||||
}
|
||||
.cal__term span { display: block; height: 100%; background: var(--ink); }
|
||||
|
||||
.cal__next {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 1px 10px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.cal__next dt { opacity: 0.8; }
|
||||
.cal__next dd { margin: 0; font-family: var(--font-num); font-weight: 700; text-align: right; }
|
||||
|
||||
.cal__controls { display: flex; justify-content: center; gap: 6px; }
|
||||
|
||||
/* Newspaper */
|
||||
.paper--news {
|
||||
background-color: var(--news-paper);
|
||||
font-family: var(--font-head);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.news__mast {
|
||||
text-align: center;
|
||||
border-bottom: 3px double var(--text);
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.news__mast h2 { font-size: 26px; letter-spacing: 0.02em; }
|
||||
.news__edition { font-size: 11px; font-style: italic; }
|
||||
.news__edition::first-letter { text-transform: uppercase; }
|
||||
|
||||
.news__lead h3 { font-size: 22px; line-height: 1.12; margin-bottom: 4px; }
|
||||
.news__lead p { margin: 0 0 8px; font-size: 14px; }
|
||||
|
||||
.news__side { display: grid; gap: 8px; border-top: 1px solid var(--text); padding-top: 8px; }
|
||||
.news__side article + article { border-top: 1px solid rgba(43, 36, 24, 0.35); padding-top: 6px; }
|
||||
.news__side h4 { font-size: 16px; line-height: 1.15; }
|
||||
.news__side p { margin: 2px 0 0; font-size: 13px; }
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Brass buttons */
|
||||
|
||||
.brass {
|
||||
min-width: 42px;
|
||||
padding: 5px 9px;
|
||||
font-family: var(--font-num);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: #3a2a0c;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(#e7c872, #b98e32 55%, #a37a24);
|
||||
border: 1px solid #6e5316;
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 245, 200, 0.7), 0 2px 3px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.brass:hover { filter: brightness(1.08); }
|
||||
|
||||
.brass:active,
|
||||
.brass[aria-pressed="true"] {
|
||||
background: linear-gradient(#a37a24, #c79d45);
|
||||
box-shadow: inset 0 2px 4px rgba(60, 40, 5, 0.6);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.brass:focus-visible {
|
||||
outline: 2px solid var(--ink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.brass--lang { margin-left: 6px; min-width: 38px; }
|
||||
.brass--wide { margin-top: 12px; padding: 8px 18px; }
|
||||
@@ -0,0 +1 @@
|
||||
Placeholder so go:embed accepts this folder. Delete once real files exist.
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"version": 1,
|
||||
|
||||
"time": {
|
||||
"daysPerWeek": 7,
|
||||
"daysPerQuarter": 91,
|
||||
"termDays": 1826,
|
||||
"msPerDay": 10000,
|
||||
"slowMsPerDay": 60000,
|
||||
"speeds": [1, 2, 4],
|
||||
"historyEvery": 7
|
||||
},
|
||||
|
||||
"limits": { "allocMaxRatio": 3, "sliderMaxRatio": 2, "taxMaxFactorOfPeak": 2 },
|
||||
|
||||
"noise": { "indicator": 0.35 },
|
||||
|
||||
"cards": {
|
||||
"recentWindow": 3,
|
||||
"recentPenalty": 0.4,
|
||||
"boostBelow": 40,
|
||||
"boostMax": 1.0,
|
||||
"defaultCooldown": 180,
|
||||
"boost": {
|
||||
"economy": { "indicator": "economy" },
|
||||
"social": { "sector": "social" },
|
||||
"health": { "sector": "health" },
|
||||
"education": { "sector": "education" },
|
||||
"security": { "sector": "police" },
|
||||
"defense": { "sector": "army" },
|
||||
"foreign": { "sector": "foreignAffairs" },
|
||||
"industry": { "sector": "industry" },
|
||||
"transport": { "sector": "transport" },
|
||||
"housing": { "sector": "housing" },
|
||||
"environment": { "sector": "environment" },
|
||||
"justice": { "sector": "justice" },
|
||||
"culture": { "sector": "culture" }
|
||||
}
|
||||
},
|
||||
|
||||
"sector": {
|
||||
"qualityCurve": 0.9,
|
||||
"industryDebtPenaltyFrom": 90,
|
||||
"industryDebtPenaltyPerPoint": 0.25
|
||||
},
|
||||
|
||||
"sectorTau": {
|
||||
"education": 365,
|
||||
"health": 90,
|
||||
"social": 60,
|
||||
"police": 14,
|
||||
"army": 120,
|
||||
"justice": 60,
|
||||
"industry": 180,
|
||||
"transport": 365,
|
||||
"infrastructure": 730,
|
||||
"housing": 365,
|
||||
"research": 365,
|
||||
"culture": 120,
|
||||
"foreignAffairs": 90,
|
||||
"environment": 365,
|
||||
"administration": 90
|
||||
},
|
||||
|
||||
"debt": {
|
||||
"freeBelow": 60,
|
||||
"baseRate": 3.0,
|
||||
"premiumFrom": 40,
|
||||
"premiumCoef": 0.0003
|
||||
},
|
||||
|
||||
"crackdown": { "unrestStart": 45 },
|
||||
|
||||
"gameOver": {
|
||||
"default": { "debtAbove": 200, "warnAbove": 160 },
|
||||
"impeachment": { "civilRightsBelow": 10, "opinionBelow": 25, "days": 60 },
|
||||
"coup": { "armyBelow": 35, "stabilityBelow": 35, "chancePerDay": 0.015 },
|
||||
"revolution": { "unrestAbove": 70, "opinionBelow": 20, "chancePerDay": 0.02 }
|
||||
},
|
||||
|
||||
"score": {
|
||||
"weights": {
|
||||
"opinion": 0.20, "stability": 0.15, "economy": 0.15, "order": 0.10,
|
||||
"civilRights": 0.10, "softPower": 0.10, "hardPower": 0.10, "calm": 0.10
|
||||
},
|
||||
"debt": { "ref": 60, "per": 0.12, "min": -10, "max": 6 },
|
||||
"growth": { "ref": 2, "per": 2.5, "min": -5, "max": 7.5 },
|
||||
"unemployment": { "ref": 6.5, "per": 1, "min": -5, "max": 3 },
|
||||
"overCap": 35,
|
||||
"grades": [[75, "S"], [66, "A"], [58, "B"], [50, "C"], [40, "D"], [0, "F"]],
|
||||
"topDecisions": 5
|
||||
},
|
||||
|
||||
"tax": {
|
||||
"income": { "coef": 0.50, "peak": 60, "opinion": 0.50, "growth": 0.030 },
|
||||
"corporate": { "coef": 0.25, "peak": 50, "opinion": 0.15, "growth": 0.060 },
|
||||
"vat": { "coef": 0.55, "peak": 40, "opinion": 0.60, "growth": 0.025 }
|
||||
},
|
||||
|
||||
"vars": {
|
||||
"growth": {
|
||||
"tau": 60, "min": -15, "max": 12, "noise": 0.02,
|
||||
"sectors": { "industry": 0.02, "infrastructure": 0.012, "research": 0.015, "education": 0.010, "transport": 0.006 },
|
||||
"econ": { "debt": -0.03, "taxGrowthDrag": -1 },
|
||||
"ind": { "unrest": -0.02 }
|
||||
},
|
||||
"unemployment": {
|
||||
"tau": 120, "min": 1, "max": 40, "noise": 0.01,
|
||||
"sectors": { "industry": -0.03, "education": -0.015, "infrastructure": -0.01 },
|
||||
"econ": { "growth": -0.6 }
|
||||
},
|
||||
"inflation": {
|
||||
"tau": 90, "min": -2, "max": 40, "noise": 0.015,
|
||||
"econ": { "deficit": 0.35, "growth": 0.15, "debt": 0.02 }
|
||||
}
|
||||
},
|
||||
|
||||
"indicators": {
|
||||
"order": {
|
||||
"tau": 30,
|
||||
"sectors": { "police": 0.35, "justice": 0.20, "administration": 0.08 },
|
||||
"econ": { "unemployment": -0.6, "crackdown": 25 },
|
||||
"ind": { "unrest": -0.30 }
|
||||
},
|
||||
"stability": {
|
||||
"tau": 45,
|
||||
"sectors": { "social": 0.15, "health": 0.08, "housing": 0.08, "justice": 0.08, "administration": 0.10 },
|
||||
"econ": { "debt": -0.15, "unemployment": -0.7, "inflation": -0.4 },
|
||||
"ind": { "unrest": -0.25, "opinion": 0.15, "order": 0.10, "economy": 0.08 }
|
||||
},
|
||||
"civilRights": {
|
||||
"tau": 40,
|
||||
"sectors": { "justice": 0.30 },
|
||||
"alloc": { "police": -20, "army": -4 },
|
||||
"econ": { "crackdown": -35 }
|
||||
},
|
||||
"opinion": {
|
||||
"tau": 30,
|
||||
"sectors": { "health": 0.22, "education": 0.08, "social": 0.28, "housing": 0.18, "transport": 0.10, "infrastructure": 0.08, "culture": 0.05, "environment": 0.04 },
|
||||
"econ": { "unemployment": -1.4, "inflation": -1.1, "growth": 1.8, "taxPressure": -1.2, "crackdown": -4 },
|
||||
"ind": { "civilRights": 0.08, "order": 0.08, "stability": 0.10 }
|
||||
},
|
||||
"economy": {
|
||||
"tau": 30,
|
||||
"sectors": { "industry": 0.30, "infrastructure": 0.15, "research": 0.12, "transport": 0.10, "education": 0.10, "administration": 0.05 },
|
||||
"econ": { "growth": 6, "unemployment": -1.2, "inflation": -0.8, "debt": -0.12 },
|
||||
"ind": { "stability": 0.10 }
|
||||
},
|
||||
"hardPower": {
|
||||
"tau": 60,
|
||||
"sectors": { "army": 0.50, "research": 0.10, "industry": 0.08 },
|
||||
"alloc": { "army": 8 },
|
||||
"econ": { "debt": -0.05 },
|
||||
"ind": { "economy": 0.08 }
|
||||
},
|
||||
"softPower": {
|
||||
"tau": 60,
|
||||
"sectors": { "culture": 0.30, "foreignAffairs": 0.35, "education": 0.08, "research": 0.08, "environment": 0.05 },
|
||||
"alloc": { "police": -4 },
|
||||
"econ": { "crackdown": -15 },
|
||||
"ind": { "civilRights": 0.15, "economy": 0.05 }
|
||||
},
|
||||
"unrest": {
|
||||
"tau": 20,
|
||||
"sectors": { "social": -0.15, "housing": -0.10 },
|
||||
"econ": { "unemployment": 2.5, "inflation": 1.0, "debt": 0.20, "crackdown": -10 },
|
||||
"ind": { "opinion": -0.50, "stability": -0.25, "order": -0.15, "civilRights": -0.12 }
|
||||
},
|
||||
"armyLoyalty": {
|
||||
"tau": 60,
|
||||
"sectors": { "army": 0.40 },
|
||||
"alloc": { "army": 15 },
|
||||
"econ": { "debt": -0.10, "inflation": -0.3 },
|
||||
"ind": { "opinion": 0.05, "stability": 0.10 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
[
|
||||
{
|
||||
"id": "cri_bond_panic_01", "category": "crises", "doc": "telegram", "sender": "rating_agency",
|
||||
"once": false, "cooldownDays": 180,
|
||||
"conditions": { "min": { "debt": 100 }, "dayMin": 60 },
|
||||
"title": { "en": "Bond markets in panic", "fr": "Panique sur le marché obligataire" },
|
||||
"body": {
|
||||
"en": "THE AGENCY DOWNGRADES SOVEREIGN DEBT. YIELDS SURGE. INVESTORS ASK FOR A CREDIBLE PLAN WITHIN DAYS. STOP.",
|
||||
"fr": "L'AGENCE DEGRADE LA DETTE SOUVERAINE. LES TAUX S'ENVOLENT. LES INVESTISSEURS EXIGENT UN PLAN CREDIBLE SOUS PEU DE JOURS. STOP."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Emergency austerity", "fr": "Austérité d'urgence" },
|
||||
"effects": [
|
||||
{ "extraSpending": -1.0 },
|
||||
{ "stat": "opinion", "delta": -6, "days": 60 },
|
||||
{ "stat": "unrest", "delta": 4, "days": 60 }
|
||||
],
|
||||
"setFlags": ["emergency_austerity"]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Ignore the market", "fr": "Ignorer le marché" },
|
||||
"effects": [
|
||||
{ "stat": "economy", "delta": -5, "days": 90 },
|
||||
{ "stat": "inflation", "delta": 1.5, "bias": true, "days": 180 },
|
||||
{ "stat": "stability", "delta": -4, "days": 60 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cri_epidemic_01", "category": "crises", "doc": "telegram", "sender": "min_health",
|
||||
"once": false, "cooldownDays": 365,
|
||||
"conditions": { "dayMin": 100 },
|
||||
"title": { "en": "Outbreak in the north", "fr": "Épidémie dans le nord" },
|
||||
"body": {
|
||||
"en": "A FAST-SPREADING FEVER REPORTED IN THREE NORTHERN DISTRICTS. HOSPITALS NEAR CAPACITY. AWAIT YOUR ORDERS. STOP.",
|
||||
"fr": "UNE FIEVRE A PROPAGATION RAPIDE SIGNALEE DANS TROIS DISTRICTS DU NORD. HOPITAUX PROCHES DE LA SATURATION. ATTENDONS VOS ORDRES. STOP."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Quarantine the region", "fr": "Mettre la région en quarantaine" },
|
||||
"effects": [
|
||||
{ "stat": "economy", "delta": -3, "days": 60 },
|
||||
{ "stat": "order", "delta": 2, "days": 30 },
|
||||
{ "stat": "civilRights", "delta": -3, "days": 60 },
|
||||
{ "stat": "opinion", "delta": 1, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Rely on the hospitals", "fr": "Compter sur les hôpitaux" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": -5, "days": 40 },
|
||||
{ "sector": "health", "delta": -4, "days": 90 },
|
||||
{ "stat": "stability", "delta": -2, "days": 40 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cri_earthquake_01", "category": "crises", "doc": "telegram", "sender": "min_interior",
|
||||
"once": false, "cooldownDays": 540,
|
||||
"conditions": { "dayMin": 150 },
|
||||
"title": { "en": "Earthquake on the coast", "fr": "Séisme sur la côte" },
|
||||
"body": {
|
||||
"en": "MAGNITUDE SEVEN EARTHQUAKE STRIKES COASTAL TOWNS. BRIDGES DOWN. HUNDREDS TRAPPED. REQUEST NATIONAL EMERGENCY. STOP.",
|
||||
"fr": "SEISME DE MAGNITUDE SEPT FRAPPE LES VILLES COTIERES. PONTS EFFONDRES. DES CENTAINES DE PERSONNES PIEGEES. DEMANDONS L'ETAT D'URGENCE NATIONAL. STOP."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Declare a national emergency", "fr": "Déclarer l'urgence nationale" },
|
||||
"effects": [
|
||||
{ "oneOff": 1.0 },
|
||||
{ "stat": "opinion", "delta": 4, "days": 45 },
|
||||
{ "stat": "order", "delta": 2, "days": 30 },
|
||||
{ "sector": "infrastructure", "delta": -3, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Leave it to local authorities", "fr": "Laisser faire les autorités locales" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": -6, "days": 45 },
|
||||
{ "stat": "unrest", "delta": 3, "days": 45 },
|
||||
{ "stat": "stability", "delta": -3, "days": 40 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cri_general_strike_01", "category": "crises", "doc": "telegram", "sender": "union",
|
||||
"once": false, "cooldownDays": 270,
|
||||
"conditions": { "min": { "unrest": 50 } },
|
||||
"title": { "en": "General strike called", "fr": "Grève générale décrétée" },
|
||||
"body": {
|
||||
"en": "THE CONFEDERATION CALLS A GENERAL STRIKE FROM TOMORROW. TRANSPORT, PORTS AND SCHOOLS TO STOP. DEMANDS ATTACHED. STOP.",
|
||||
"fr": "LA CONFEDERATION DECRETE UNE GREVE GENERALE A COMPTER DE DEMAIN. TRANSPORTS, PORTS ET ECOLES A L'ARRET. REVENDICATIONS JOINTES. STOP."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Concede", "fr": "Céder" },
|
||||
"effects": [
|
||||
{ "extraSpending": 0.5 },
|
||||
{ "stat": "unrest", "delta": -6, "days": 30 },
|
||||
{ "stat": "economy", "delta": -2, "days": 30 }
|
||||
],
|
||||
"setFlags": ["concessions"]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Break the strike", "fr": "Briser la grève" },
|
||||
"effects": [
|
||||
{ "stat": "order", "delta": 3, "days": 21 },
|
||||
{ "stat": "unrest", "delta": 3, "days": 40 },
|
||||
{ "stat": "civilRights", "delta": -4 },
|
||||
{ "stat": "economy", "delta": -4, "days": 40 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cri_coup_whispers_01", "category": "crises", "doc": "telegram", "sender": "intelligence",
|
||||
"once": false, "cooldownDays": 270,
|
||||
"conditions": { "max": { "armyLoyalty": 45 }, "dayMin": 60 },
|
||||
"title": { "en": "Officers are plotting", "fr": "Des officiers complotent" },
|
||||
"body": {
|
||||
"en": "INTERCEPTED CONVERSATIONS SUGGEST A GROUP OF SENIOR OFFICERS DISCUSSING YOUR REMOVAL. NAMES TO FOLLOW. STOP.",
|
||||
"fr": "DES CONVERSATIONS INTERCEPTEES LAISSENT PENSER QU'UN GROUPE D'OFFICIERS SUPERIEURS DISCUTE DE VOTRE DESTITUTION. NOMS A SUIVRE. STOP."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Arrest the ringleaders", "fr": "Arrêter les meneurs" },
|
||||
"effects": [
|
||||
{ "stat": "armyLoyalty", "delta": 6, "days": 60 },
|
||||
{ "stat": "hardPower", "delta": -3, "days": 60 },
|
||||
{ "stat": "civilRights", "delta": -3 },
|
||||
{ "stat": "stability", "delta": -2, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Raise the officers' pay", "fr": "Augmenter la solde des officiers" },
|
||||
"effects": [
|
||||
{ "extraSpending": 0.4 },
|
||||
{ "stat": "armyLoyalty", "delta": 8, "days": 90 }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
[
|
||||
{
|
||||
"id": "cul_state_broadcaster_01", "category": "culture", "doc": "memo", "sender": "min_culture",
|
||||
"title": { "en": "Put the national broadcaster under ministry control?", "fr": "Placer l'audiovisuel public sous tutelle ministérielle ?" },
|
||||
"body": {
|
||||
"en": "The national broadcaster airs criticism of the government every evening. Placing it under ministerial control would give you a calmer press.",
|
||||
"fr": "L'audiovisuel public diffuse chaque soir des critiques contre le gouvernement. Le placer sous tutelle ministérielle vous offrirait une presse plus calme."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Take control", "fr": "Prendre le contrôle" },
|
||||
"effects": [
|
||||
{ "stat": "stability", "delta": 3, "bias": true, "days": 365 },
|
||||
{ "stat": "civilRights", "delta": -3, "bias": true, "days": 365 },
|
||||
{ "stat": "softPower", "delta": -2, "days": 90 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Leave it independent", "fr": "Le laisser indépendant" },
|
||||
"effects": [
|
||||
{ "stat": "stability", "delta": -1, "days": 30 },
|
||||
{ "stat": "opinion", "delta": 1, "days": 30 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cul_film_fund_01", "category": "culture", "doc": "letter", "sender": "min_culture",
|
||||
"title": { "en": "A national film fund", "fr": "Un fonds national pour le cinéma" },
|
||||
"body": {
|
||||
"en": "Local directors ask for a fund to finance films. A single festival prize abroad could do more for our image than a year of speeches.",
|
||||
"fr": "Des réalisateurs locaux demandent un fonds pour financer des films. Un seul prix dans un festival étranger pourrait faire plus pour notre image qu'une année de discours."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Create the fund", "fr": "Créer le fonds" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.15 },
|
||||
{ "sector": "culture", "delta": 4, "days": 180 },
|
||||
{ "stat": "softPower", "delta": 3, "days": 90 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Decline", "fr": "Décliner" },
|
||||
"effects": [{ "sector": "culture", "delta": -2, "days": 120 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cul_football_holiday_01", "category": "culture", "doc": "memo", "sender": "press_office",
|
||||
"title": { "en": "A holiday for the football victory", "fr": "Un jour férié pour la victoire au football" },
|
||||
"body": {
|
||||
"en": "The national team won the continental final. The streets are full and everyone is asking for a day off. Employers are not.",
|
||||
"fr": "L'équipe nationale a remporté la finale continentale. Les rues sont pleines et tout le monde réclame un jour de congé. Les employeurs, non."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Declare a holiday", "fr": "Décréter un jour férié" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.1 },
|
||||
{ "stat": "opinion", "delta": 4, "days": 15 },
|
||||
{ "stat": "economy", "delta": -1, "days": 15 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Business as usual", "fr": "Travail comme d'habitude" },
|
||||
"effects": [{ "stat": "opinion", "delta": -2, "days": 20 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
[
|
||||
{
|
||||
"id": "def_fighter_jets_01", "category": "defense", "doc": "memo", "sender": "min_defense",
|
||||
"title": { "en": "Buy new fighter jets", "fr": "Acheter de nouveaux avions de chasse" },
|
||||
"body": {
|
||||
"en": "The air force's fleet is ageing. The Defence Ministry has a deal for a squadron of modern fighters, at a price that will be remembered in the next budget.",
|
||||
"fr": "La flotte de l'armée de l'air vieillit. Le ministère de la Défense a trouvé un accord pour un escadron de chasseurs modernes, à un prix dont le prochain budget se souviendra."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Sign the contract", "fr": "Signer le contrat" },
|
||||
"effects": [
|
||||
{ "oneOff": 1.0 },
|
||||
{ "stat": "hardPower", "delta": 4, "bias": true, "days": 730 },
|
||||
{ "stat": "armyLoyalty", "delta": 4, "days": 60 },
|
||||
{ "stat": "opinion", "delta": -2, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Cancel the deal", "fr": "Annuler l'accord" },
|
||||
"effects": [
|
||||
{ "stat": "armyLoyalty", "delta": -4, "days": 60 },
|
||||
{ "stat": "hardPower", "delta": -2, "bias": true, "days": 365 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "def_conscription_01", "category": "defense", "doc": "memo", "sender": "general",
|
||||
"title": { "en": "Reintroduce conscription", "fr": "Rétablir le service militaire" },
|
||||
"body": {
|
||||
"en": "The General Staff argues that a professional army is too small. Conscription would fill the barracks and trim the youth unemployment figures. Parents will not be pleased.",
|
||||
"fr": "L'état-major juge l'armée de métier trop petite. Le service militaire remplirait les casernes et réduirait le chômage des jeunes. Les parents ne seront pas ravis."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Reintroduce it", "fr": "Le rétablir" },
|
||||
"effects": [
|
||||
{ "stat": "hardPower", "delta": 5, "bias": true, "days": 730 },
|
||||
{ "stat": "unemployment", "delta": -0.3, "bias": true, "days": 365 },
|
||||
{ "stat": "opinion", "delta": -5, "days": 60 },
|
||||
{ "stat": "unrest", "delta": 2, "days": 60 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||
"effects": [{ "stat": "armyLoyalty", "delta": -2, "days": 30 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "def_exercises_01", "category": "defense", "doc": "letter", "sender": "general",
|
||||
"title": { "en": "Joint exercises with an ally", "fr": "Exercices conjoints avec un allié" },
|
||||
"body": {
|
||||
"en": "A friendly army proposes joint manoeuvres along the shared border. Neighbours will read the signal in their own way.",
|
||||
"fr": "Une armée amie propose des manœuvres conjointes le long de la frontière commune. Les voisins liront ce signal à leur manière."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Accept", "fr": "Accepter" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.1 },
|
||||
{ "stat": "hardPower", "delta": 3, "days": 60 },
|
||||
{ "stat": "softPower", "delta": 3, "days": 60 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Decline", "fr": "Décliner" },
|
||||
"effects": [{ "stat": "armyLoyalty", "delta": -2, "days": 30 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "def_parade_01", "category": "defense", "doc": "memo", "sender": "general",
|
||||
"title": { "en": "Military parade on national day", "fr": "Défilé militaire pour la fête nationale" },
|
||||
"body": {
|
||||
"en": "The General Staff proposes a full parade through the capital. It costs money and closes the main avenue, but flags and drums have a way of lifting spirits.",
|
||||
"fr": "L'état-major propose un grand défilé dans la capitale. Cela coûte de l'argent et ferme l'avenue principale, mais drapeaux et tambours ont le don de remonter le moral."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Hold the parade", "fr": "Organiser le défilé" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.15 },
|
||||
{ "stat": "opinion", "delta": 3, "days": 20 },
|
||||
{ "stat": "armyLoyalty", "delta": 3, "days": 30 },
|
||||
{ "stat": "hardPower", "delta": 1, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Cancel it", "fr": "L'annuler" },
|
||||
"effects": [
|
||||
{ "stat": "armyLoyalty", "delta": -2, "days": 30 },
|
||||
{ "stat": "opinion", "delta": -1, "days": 20 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "def_garrison_closure_01", "category": "defense", "doc": "memo", "sender": "min_defense",
|
||||
"conditions": { "min": { "debt": 50 } },
|
||||
"title": { "en": "Close an unprofitable garrison", "fr": "Fermer une garnison peu rentable" },
|
||||
"body": {
|
||||
"en": "A remote garrison costs more than it protects. Closing it saves money but empties a small town of its main employer and its officers of a post.",
|
||||
"fr": "Une garnison isolée coûte plus qu'elle ne protège. La fermer ferait des économies mais priverait une petite ville de son principal employeur et des officiers d'un poste."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Close it", "fr": "La fermer" },
|
||||
"effects": [
|
||||
{ "extraSpending": -0.2 },
|
||||
{ "stat": "armyLoyalty", "delta": -4, "days": 60 },
|
||||
{ "stat": "hardPower", "delta": -2, "bias": true, "days": 365 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Keep it open", "fr": "La maintenir" },
|
||||
"effects": [{ "extraSpending": 0.15 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,193 @@
|
||||
[
|
||||
{
|
||||
"id": "eco_wealth_tax_01",
|
||||
"category": "economy",
|
||||
"doc": "memo",
|
||||
"sender": "min_finance",
|
||||
"conditions": { "notFlags": ["wealth_tax"] },
|
||||
"title": { "en": "Tax the wealthy?", "fr": "Taxer les plus riches ?" },
|
||||
"body": {
|
||||
"en": "The Finance Ministry proposes a surcharge on the highest incomes. Polls show the idea is popular, but business leaders are already drafting letters to the press.",
|
||||
"fr": "Le ministère des Finances propose une surtaxe sur les plus hauts revenus. Les sondages y sont favorables, mais les chefs d'entreprise rédigent déjà des tribunes pour la presse."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Sign the decree", "fr": "Signer le décret" },
|
||||
"effects": [
|
||||
{ "revenue": "income", "delta": 4 },
|
||||
{ "stat": "opinion", "delta": 4, "days": 30 },
|
||||
{ "stat": "economy", "delta": -2, "bias": true, "days": 365 }
|
||||
],
|
||||
"setFlags": ["wealth_tax"],
|
||||
"followUp": { "id": "eco_capital_flight_01", "inDays": 45 }
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Shelve the proposal", "fr": "Classer le dossier" },
|
||||
"effects": [{ "stat": "opinion", "delta": -2, "days": 30 }, { "stat": "unrest", "delta": 2, "days": 30 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eco_capital_flight_01",
|
||||
"category": "economy",
|
||||
"doc": "report",
|
||||
"sender": "central_bank",
|
||||
"followUpOnly": true,
|
||||
"title": { "en": "Capital is leaving the country", "fr": "Les capitaux quittent le pays" },
|
||||
"body": {
|
||||
"en": "Since the new tax, wealthy savers are moving assets abroad. The Central Bank warns that reserves are thinning and asks what the government intends to do.",
|
||||
"fr": "Depuis la nouvelle taxe, les épargnants fortunés transfèrent leurs avoirs à l'étranger. La Banque centrale alerte sur l'érosion des réserves et demande ce que le gouvernement compte faire."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Impose capital controls", "fr": "Imposer un contrôle des capitaux" },
|
||||
"effects": [
|
||||
{ "stat": "economy", "delta": -2, "days": 60 },
|
||||
{ "stat": "softPower", "delta": -4, "days": 60 },
|
||||
{ "stat": "civilRights", "delta": -2 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Let the market decide", "fr": "Laisser faire le marché" },
|
||||
"effects": [{ "stat": "economy", "delta": -5, "days": 90 }, { "revenue": "other", "delta": -0.4 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eco_rate_cut_01",
|
||||
"category": "economy",
|
||||
"doc": "letter",
|
||||
"sender": "central_bank",
|
||||
"conditions": { "max": { "inflation": 5 } },
|
||||
"title": { "en": "Cut interest rates?", "fr": "Baisser les taux d'intérêt ?" },
|
||||
"body": {
|
||||
"en": "The Central Bank asks for the government's backing to cut rates and revive credit. Growth would pick up, and prices would likely follow.",
|
||||
"fr": "La Banque centrale demande le soutien du gouvernement pour baisser ses taux et relancer le crédit. La croissance repartirait, et les prix probablement aussi."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Back the cut", "fr": "Soutenir la baisse" },
|
||||
"effects": [
|
||||
{ "stat": "growth", "delta": 0.5, "bias": true, "days": 180 },
|
||||
{ "stat": "inflation", "delta": 1, "bias": true, "days": 180 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Hold rates", "fr": "Maintenir les taux" },
|
||||
"effects": [
|
||||
{ "stat": "growth", "delta": -0.2, "bias": true, "days": 180 },
|
||||
{ "stat": "inflation", "delta": -0.3, "bias": true, "days": 180 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eco_vat_holiday_01",
|
||||
"category": "economy",
|
||||
"doc": "memo",
|
||||
"sender": "min_finance",
|
||||
"title": { "en": "VAT holiday on essentials", "fr": "Pause de TVA sur les produits de base" },
|
||||
"body": {
|
||||
"en": "A temporary VAT cut on food and medicine would be felt in every household. The Treasury reminds you that every point of revenue has to come from somewhere.",
|
||||
"fr": "Une baisse temporaire de la TVA sur l'alimentation et les médicaments serait ressentie dans tous les foyers. Le Trésor rappelle que chaque point de recettes doit venir de quelque part."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Cut the VAT", "fr": "Baisser la TVA" },
|
||||
"effects": [{ "revenue": "vat", "delta": -2 }, { "stat": "opinion", "delta": 3, "days": 30 }]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Keep the revenue", "fr": "Garder les recettes" },
|
||||
"effects": [{ "stat": "opinion", "delta": -1, "days": 20 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eco_austerity_01",
|
||||
"category": "economy",
|
||||
"doc": "memo",
|
||||
"sender": "min_finance",
|
||||
"conditions": { "min": { "debt": 70 }, "notFlags": ["austerity"] },
|
||||
"title": { "en": "A package of spending cuts", "fr": "Un plan de réduction des dépenses" },
|
||||
"body": {
|
||||
"en": "With debt this high, the Finance Ministry has drawn up a package of permanent cuts to administration and subsidies. Lenders will notice. So will the streets.",
|
||||
"fr": "Avec une dette aussi élevée, le ministère des Finances a préparé un plan de coupes durables dans l'administration et les subventions. Les prêteurs le remarqueront. La rue aussi."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Approve the cuts", "fr": "Approuver les coupes" },
|
||||
"effects": [
|
||||
{ "extraSpending": -0.8 },
|
||||
{ "stat": "opinion", "delta": -5, "days": 60 },
|
||||
{ "stat": "unrest", "delta": 4, "days": 60 }
|
||||
],
|
||||
"setFlags": ["austerity"]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||
"effects": [
|
||||
{ "stat": "economy", "delta": -3, "bias": true, "days": 180 },
|
||||
{ "stat": "stability", "delta": -2, "days": 60 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eco_stake_sale_01",
|
||||
"category": "economy",
|
||||
"doc": "memo",
|
||||
"sender": "min_finance",
|
||||
"conditions": { "notFlags": ["airline_sold"] },
|
||||
"title": { "en": "Sell the national airline?", "fr": "Vendre la compagnie aérienne nationale ?" },
|
||||
"body": {
|
||||
"en": "A foreign consortium offers a generous price for the state's stake in the national airline. Unions call it a betrayal. The Treasury calls it a windfall.",
|
||||
"fr": "Un consortium étranger propose un prix généreux pour la participation de l'État dans la compagnie aérienne nationale. Les syndicats y voient une trahison. Le Trésor, une aubaine."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Accept the offer", "fr": "Accepter l'offre" },
|
||||
"effects": [
|
||||
{ "oneOff": -1.2 },
|
||||
{ "stat": "opinion", "delta": -3, "days": 40 },
|
||||
{ "stat": "economy", "delta": 2, "bias": true, "days": 365 }
|
||||
],
|
||||
"setFlags": ["airline_sold"]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Keep it public", "fr": "La garder publique" },
|
||||
"effects": [{ "stat": "opinion", "delta": 1, "days": 30 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eco_amnesty_01",
|
||||
"category": "economy",
|
||||
"doc": "letter",
|
||||
"sender": "min_finance",
|
||||
"title": { "en": "Offshore money amnesty", "fr": "Amnistie fiscale sur les avoirs offshore" },
|
||||
"body": {
|
||||
"en": "An amnesty would bring undeclared fortunes back home and fill the Treasury at once. Critics say it rewards those who cheated.",
|
||||
"fr": "Une amnistie ferait revenir au pays des fortunes non déclarées et remplirait aussitôt les caisses du Trésor. Les critiques y voient une prime aux tricheurs."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Grant the amnesty", "fr": "Accorder l'amnistie" },
|
||||
"effects": [
|
||||
{ "oneOff": -0.8 },
|
||||
{ "stat": "softPower", "delta": -2, "days": 60 },
|
||||
{ "stat": "opinion", "delta": -3, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": { "label": { "en": "Refuse", "fr": "Refuser" }, "effects": [{ "stat": "opinion", "delta": 1, "days": 30 }] }
|
||||
},
|
||||
{
|
||||
"id": "eco_min_wage_01",
|
||||
"category": "economy",
|
||||
"doc": "petition",
|
||||
"sender": "union",
|
||||
"title": { "en": "Raise the minimum wage", "fr": "Relever le salaire minimum" },
|
||||
"body": {
|
||||
"en": "The Labour Confederation presents a petition with half a million signatures demanding a higher minimum wage. Employers warn that jobs will go.",
|
||||
"fr": "La Confédération du travail présente une pétition de cinq cent mille signatures réclamant un salaire minimum plus élevé. Le patronat prévient que des emplois disparaîtront."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Raise it", "fr": "Le relever" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": 4, "days": 45 },
|
||||
{ "stat": "unemployment", "delta": 0.4, "bias": true, "days": 365 },
|
||||
{ "stat": "economy", "delta": -1, "bias": true, "days": 365 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||
"effects": [{ "stat": "unrest", "delta": 3, "days": 45 }, { "stat": "opinion", "delta": -3, "days": 45 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
[
|
||||
{
|
||||
"id": "edu_free_university_01", "category": "education", "doc": "memo", "sender": "min_education",
|
||||
"title": { "en": "Free university tuition", "fr": "Université gratuite" },
|
||||
"body": {
|
||||
"en": "Student groups demand an end to tuition fees. The Education Ministry says it would widen access but add a lasting cost.",
|
||||
"fr": "Les associations étudiantes exigent la fin des frais d'inscription. Le ministère de l'Éducation estime que cela élargirait l'accès mais ajouterait une charge durable."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Abolish the fees", "fr": "Supprimer les frais" },
|
||||
"effects": [
|
||||
{ "extraSpending": 0.5 },
|
||||
{ "stat": "opinion", "delta": 4, "days": 45 },
|
||||
{ "sector": "education", "delta": 4, "days": 365 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Keep the fees", "fr": "Garder les frais" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": -2, "days": 30 },
|
||||
{ "stat": "unrest", "delta": 2, "days": 30 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "edu_curriculum_01", "category": "education", "doc": "memo", "sender": "min_education",
|
||||
"title": { "en": "Rewrite the history curriculum", "fr": "Réécrire le programme d'histoire" },
|
||||
"body": {
|
||||
"en": "A new national curriculum would give pupils a single, patriotic account of the country's past. Historians call it propaganda.",
|
||||
"fr": "Un nouveau programme national donnerait aux élèves un récit unique et patriotique du passé du pays. Les historiens y voient de la propagande."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Adopt it", "fr": "L'adopter" },
|
||||
"effects": [
|
||||
{ "stat": "stability", "delta": 3, "bias": true, "days": 365 },
|
||||
{ "stat": "civilRights", "delta": -3, "bias": true, "days": 365 },
|
||||
{ "stat": "softPower", "delta": -2, "days": 90 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Leave the curriculum", "fr": "Laisser le programme" },
|
||||
"effects": [{ "stat": "stability", "delta": -1, "days": 30 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "edu_teacher_pay_01", "category": "education", "doc": "petition", "sender": "union",
|
||||
"title": { "en": "Teachers ask for a pay rise", "fr": "Les enseignants réclament une revalorisation" },
|
||||
"body": {
|
||||
"en": "Teachers' unions say salaries no longer keep pace with living costs and warn that classrooms will empty by autumn.",
|
||||
"fr": "Les syndicats d'enseignants affirment que les salaires ne suivent plus le coût de la vie et préviennent que les classes se videront d'ici l'automne."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Grant the rise", "fr": "Accorder la hausse" },
|
||||
"effects": [
|
||||
{ "extraSpending": 0.4 },
|
||||
{ "sector": "education", "delta": 5, "days": 180 },
|
||||
{ "stat": "opinion", "delta": 2, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||
"effects": [
|
||||
{ "sector": "education", "delta": -4, "days": 180 },
|
||||
{ "stat": "unrest", "delta": 2, "days": 30 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "edu_school_meals_01", "category": "education", "doc": "memo", "sender": "min_education",
|
||||
"title": { "en": "Free school meals", "fr": "Repas scolaires gratuits" },
|
||||
"body": {
|
||||
"en": "Many children arrive at school hungry. Free meals would help them learn, and help stretched families too.",
|
||||
"fr": "Beaucoup d'enfants arrivent à l'école le ventre vide. Des repas gratuits les aideraient à apprendre, et aideraient aussi les familles en difficulté."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Fund the meals", "fr": "Financer les repas" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.3 },
|
||||
{ "stat": "opinion", "delta": 3, "days": 45 },
|
||||
{ "sector": "education", "delta": 2, "days": 180 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Decline", "fr": "Décliner" },
|
||||
"effects": [{ "stat": "opinion", "delta": -1, "days": 30 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"id": "env_carbon_tax_01", "category": "environment", "doc": "memo", "sender": "min_environment",
|
||||
"title": { "en": "A carbon tax", "fr": "Une taxe carbone" },
|
||||
"body": {
|
||||
"en": "A tax on carbon emissions would pay for cleaner air and impress foreign partners. Factories and commuters would pay for it first.",
|
||||
"fr": "Une taxe sur les émissions de carbone financerait un air plus propre et impressionnerait les partenaires étrangers. Les usines et les travailleurs pendulaires en paieraient d'abord le prix."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Introduce the tax", "fr": "Instaurer la taxe" },
|
||||
"effects": [
|
||||
{ "revenue": "other", "delta": 0.6 },
|
||||
{ "sector": "environment", "delta": 5, "days": 180 },
|
||||
{ "sector": "industry", "delta": -2, "days": 120 },
|
||||
{ "stat": "opinion", "delta": -3, "days": 45 },
|
||||
{ "stat": "softPower", "delta": 3, "days": 90 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||
"effects": [
|
||||
{ "stat": "softPower", "delta": -2, "days": 60 },
|
||||
{ "sector": "environment", "delta": -2, "days": 120 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "env_national_park_01", "category": "environment", "doc": "letter", "sender": "min_environment",
|
||||
"title": { "en": "Create a national park", "fr": "Créer un parc national" },
|
||||
"body": {
|
||||
"en": "A forested valley could be protected for good. The mining lobby has other plans for it.",
|
||||
"fr": "Une vallée boisée pourrait être protégée définitivement. Le lobby minier avait d'autres projets pour elle."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Protect the valley", "fr": "Protéger la vallée" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.2 },
|
||||
{ "sector": "environment", "delta": 4, "days": 180 },
|
||||
{ "stat": "softPower", "delta": 2, "days": 60 },
|
||||
{ "stat": "economy", "delta": -1, "bias": true, "days": 365 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Open it to mining", "fr": "L'ouvrir aux mines" },
|
||||
"effects": [
|
||||
{ "sector": "environment", "delta": -2, "days": 120 },
|
||||
{ "stat": "economy", "delta": 1, "days": 90 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "env_coal_phaseout_01", "category": "environment", "doc": "memo", "sender": "min_environment",
|
||||
"title": { "en": "Phase out coal", "fr": "Sortir du charbon" },
|
||||
"body": {
|
||||
"en": "Closing the coal plants by decade's end would cut emissions sharply. It would also cost jobs in mining regions that have voted for you.",
|
||||
"fr": "Fermer les centrales à charbon d'ici la fin de la décennie réduirait fortement les émissions. Cela coûterait aussi des emplois dans des régions minières qui ont voté pour vous."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Set the date", "fr": "Fixer l'échéance" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.5 },
|
||||
{ "sector": "environment", "delta": 6, "days": 365 },
|
||||
{ "stat": "unemployment", "delta": 0.3, "bias": true, "days": 365 },
|
||||
{ "sector": "industry", "delta": -3, "days": 180 },
|
||||
{ "stat": "softPower", "delta": 3, "days": 90 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Keep burning", "fr": "Continuer à brûler" },
|
||||
"effects": [
|
||||
{ "sector": "environment", "delta": -3, "days": 180 },
|
||||
{ "stat": "softPower", "delta": -2, "days": 60 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "env_flood_defences_01", "category": "environment", "doc": "report", "sender": "min_environment",
|
||||
"title": { "en": "Flood defences for the river towns", "fr": "Digues pour les villes riveraines" },
|
||||
"body": {
|
||||
"en": "Two floods in three years have drowned the same towns. Engineers say a line of defences would pay for itself within a decade.",
|
||||
"fr": "Deux crues en trois ans ont noyé les mêmes villes. Les ingénieurs affirment qu'une ligne de digues se rentabiliserait en dix ans."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Build the defences", "fr": "Construire les digues" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.7 },
|
||||
{ "sector": "environment", "delta": 3, "days": 180 },
|
||||
{ "sector": "infrastructure", "delta": 3, "days": 180 },
|
||||
{ "stat": "opinion", "delta": 2, "days": 40 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Postpone", "fr": "Reporter" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": -2, "days": 40 },
|
||||
{ "sector": "infrastructure", "delta": -2, "days": 180 }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
[
|
||||
{
|
||||
"id": "for_trade_pact_01", "category": "foreign", "doc": "memo", "sender": "min_foreign",
|
||||
"conditions": { "notFlags": ["trade_pact"] },
|
||||
"title": { "en": "Sign a regional trade pact", "fr": "Signer un pacte commercial régional" },
|
||||
"body": {
|
||||
"en": "Our neighbours offer a trade pact opening their markets to our exporters. Farmers worry about cheap imports in return.",
|
||||
"fr": "Nos voisins proposent un pacte commercial ouvrant leurs marchés à nos exportateurs. Les agriculteurs craignent en retour des importations bon marché."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Sign the pact", "fr": "Signer le pacte" },
|
||||
"effects": [
|
||||
{ "stat": "economy", "delta": 3, "bias": true, "days": 365 },
|
||||
{ "stat": "growth", "delta": 0.3, "bias": true, "days": 365 },
|
||||
{ "stat": "softPower", "delta": 2, "days": 60 },
|
||||
{ "stat": "opinion", "delta": -2, "days": 40 }
|
||||
],
|
||||
"setFlags": ["trade_pact"]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Decline", "fr": "Décliner" },
|
||||
"effects": [
|
||||
{ "stat": "softPower", "delta": -2, "days": 60 },
|
||||
{ "stat": "economy", "delta": -1, "days": 60 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "for_sanctions_01", "category": "foreign", "doc": "memo", "sender": "min_foreign",
|
||||
"title": { "en": "Join sanctions against a neighbour", "fr": "Se joindre aux sanctions contre un voisin" },
|
||||
"body": {
|
||||
"en": "Our allies ask us to join sanctions against a neighbour accused of violating the border. Trade with that neighbour would suffer.",
|
||||
"fr": "Nos alliés nous demandent de nous joindre aux sanctions contre un voisin accusé d'avoir violé la frontière. Le commerce avec ce voisin en souffrirait."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Join the sanctions", "fr": "Appliquer les sanctions" },
|
||||
"effects": [
|
||||
{ "stat": "softPower", "delta": 4, "days": 90 },
|
||||
{ "stat": "economy", "delta": -3, "bias": true, "days": 180 },
|
||||
{ "stat": "hardPower", "delta": 1, "days": 60 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Stay neutral", "fr": "Rester neutre" },
|
||||
"effects": [{ "stat": "softPower", "delta": -3, "days": 60 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "for_summit_01", "category": "foreign", "doc": "letter", "sender": "min_foreign",
|
||||
"title": { "en": "Host the regional summit", "fr": "Accueillir le sommet régional" },
|
||||
"body": {
|
||||
"en": "The capital has been offered the regional summit. Delegations, cameras and security bills will all arrive together.",
|
||||
"fr": "La capitale se voit proposer l'organisation du sommet régional. Délégations, caméras et factures de sécurité arriveront ensemble."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Host it", "fr": "L'accueillir" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.4 },
|
||||
{ "stat": "softPower", "delta": 6, "days": 90 },
|
||||
{ "stat": "opinion", "delta": 2, "days": 30 },
|
||||
{ "stat": "order", "delta": -1, "days": 14 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Decline", "fr": "Décliner" },
|
||||
"effects": [{ "stat": "softPower", "delta": -2, "days": 60 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "for_refugee_corridor_01", "category": "foreign", "doc": "report", "sender": "min_foreign",
|
||||
"title": { "en": "A corridor for refugees", "fr": "Un corridor pour les réfugiés" },
|
||||
"body": {
|
||||
"en": "Thousands are fleeing conflict across the border. Aid agencies ask us to open a corridor. Reception centres would be needed, and some voters are wary.",
|
||||
"fr": "Des milliers de personnes fuient un conflit de l'autre côté de la frontière. Les agences humanitaires nous demandent d'ouvrir un corridor. Des centres d'accueil seraient nécessaires, et certains électeurs sont méfiants."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Open the corridor", "fr": "Ouvrir le corridor" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.3 },
|
||||
{ "stat": "softPower", "delta": 5, "days": 90 },
|
||||
{ "stat": "opinion", "delta": -4, "days": 60 },
|
||||
{ "stat": "unrest", "delta": 2, "days": 60 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Close the border", "fr": "Fermer la frontière" },
|
||||
"effects": [
|
||||
{ "stat": "softPower", "delta": -5, "days": 90 },
|
||||
{ "stat": "opinion", "delta": 2, "days": 30 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "for_expel_ambassador_01", "category": "foreign", "doc": "memo", "sender": "min_foreign",
|
||||
"title": { "en": "Expel a hostile ambassador", "fr": "Expulser un ambassadeur hostile" },
|
||||
"body": {
|
||||
"en": "A foreign ambassador gave a speech mocking the government. Expelling the ambassador would play well at home and badly in the embassies.",
|
||||
"fr": "Un ambassadeur étranger a prononcé un discours moquant le gouvernement. L'expulser serait bien reçu dans le pays et mal dans les ambassades."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Expel", "fr": "Expulser" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": 3, "days": 30 },
|
||||
{ "stat": "softPower", "delta": -3, "days": 60 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Ignore it", "fr": "Ignorer l'affront" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": -2, "days": 30 },
|
||||
{ "stat": "softPower", "delta": 1, "days": 30 }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
[
|
||||
{
|
||||
"id": "hlt_hospital_funds_01", "category": "health", "doc": "memo", "sender": "min_health",
|
||||
"title": { "en": "Emergency hospital funding", "fr": "Financement d'urgence des hôpitaux" },
|
||||
"body": {
|
||||
"en": "Regional hospitals report overflowing wards and unpaid suppliers. The Health Ministry requests an emergency payment to keep them open.",
|
||||
"fr": "Les hôpitaux régionaux signalent des services débordés et des fournisseurs impayés. Le ministère de la Santé demande une aide d'urgence pour les maintenir ouverts."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Release the funds", "fr": "Débloquer les fonds" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.6 },
|
||||
{ "sector": "health", "delta": 6, "days": 120 },
|
||||
{ "stat": "opinion", "delta": 3, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||
"effects": [
|
||||
{ "sector": "health", "delta": -3, "days": 120 },
|
||||
{ "stat": "opinion", "delta": -2, "days": 30 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hlt_vaccination_01", "category": "health", "doc": "report", "sender": "min_health",
|
||||
"title": { "en": "National vaccination drive", "fr": "Campagne nationale de vaccination" },
|
||||
"body": {
|
||||
"en": "Immunisation rates have fallen below the safe threshold. A national campaign would cost little compared to an outbreak.",
|
||||
"fr": "Les taux de vaccination sont passés sous le seuil de sécurité. Une campagne nationale coûterait peu comparée à une épidémie."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Launch the campaign", "fr": "Lancer la campagne" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.3 },
|
||||
{ "sector": "health", "delta": 4, "days": 90 },
|
||||
{ "stat": "opinion", "delta": 1, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Postpone", "fr": "Reporter" },
|
||||
"effects": [{ "sector": "health", "delta": -2, "days": 120 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hlt_private_clinics_01", "category": "health", "doc": "memo", "sender": "min_health",
|
||||
"title": { "en": "Open health care to private clinics?", "fr": "Ouvrir la santé aux cliniques privées ?" },
|
||||
"body": {
|
||||
"en": "Allowing private clinics into the public system would save money and shorten some queues. Doctors fear a two-tier system.",
|
||||
"fr": "Admettre les cliniques privées dans le système public ferait des économies et raccourcirait certaines files d'attente. Les médecins craignent un système à deux vitesses."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Open the system", "fr": "Ouvrir le système" },
|
||||
"effects": [
|
||||
{ "extraSpending": -0.4 },
|
||||
{ "sector": "health", "delta": -3, "days": 180 },
|
||||
{ "stat": "economy", "delta": 1, "bias": true, "days": 365 },
|
||||
{ "stat": "opinion", "delta": -3, "days": 40 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Keep it public", "fr": "Rester public" },
|
||||
"effects": [{ "sector": "health", "delta": 2, "days": 90 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hlt_foreign_doctors_01", "category": "health", "doc": "letter", "sender": "min_health",
|
||||
"title": { "en": "Recruit doctors from abroad", "fr": "Recruter des médecins à l'étranger" },
|
||||
"body": {
|
||||
"en": "Rural clinics have no doctors. The ministry proposes a fast-track visa for foreign physicians. Some will object to the arrivals.",
|
||||
"fr": "Les dispensaires ruraux n'ont plus de médecins. Le ministère propose un visa accéléré pour les praticiens étrangers. Certains s'opposeront à ces arrivées."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Fast-track the visas", "fr": "Accélérer les visas" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.2 },
|
||||
{ "sector": "health", "delta": 5, "days": 120 },
|
||||
{ "stat": "softPower", "delta": 2, "days": 60 },
|
||||
{ "stat": "opinion", "delta": -1, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Decline", "fr": "Décliner" },
|
||||
"effects": [{ "sector": "health", "delta": -3, "days": 120 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
[
|
||||
{
|
||||
"id": "hou_rent_cap_01", "category": "housing", "doc": "petition", "sender": "mayor",
|
||||
"title": { "en": "Cap rents in the cities", "fr": "Plafonner les loyers en ville" },
|
||||
"body": {
|
||||
"en": "Tenants' groups ask for a cap on rents. Landlords reply that they will stop building and stop repairing.",
|
||||
"fr": "Les associations de locataires demandent un plafonnement des loyers. Les propriétaires répondent qu'ils cesseront de construire et de rénover."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Cap the rents", "fr": "Plafonner les loyers" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": 4, "days": 45 },
|
||||
{ "sector": "housing", "delta": -5, "days": 180 },
|
||||
{ "stat": "economy", "delta": -1, "bias": true, "days": 365 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Leave the market alone", "fr": "Laisser faire le marché" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": -2, "days": 30 },
|
||||
{ "stat": "unrest", "delta": 2, "days": 30 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hou_social_housing_01", "category": "housing", "doc": "memo", "sender": "min_housing",
|
||||
"title": { "en": "A social housing programme", "fr": "Un programme de logements sociaux" },
|
||||
"body": {
|
||||
"en": "The Housing Ministry proposes ten thousand new social homes over three years. Waiting lists are at record length.",
|
||||
"fr": "Le ministère du Logement propose dix mille nouveaux logements sociaux en trois ans. Les listes d'attente battent des records."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Launch the programme", "fr": "Lancer le programme" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.8 },
|
||||
{ "sector": "housing", "delta": 6, "days": 365 },
|
||||
{ "stat": "opinion", "delta": 2, "days": 45 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Decline", "fr": "Décliner" },
|
||||
"effects": [
|
||||
{ "sector": "housing", "delta": -2, "days": 180 },
|
||||
{ "stat": "unrest", "delta": 2, "days": 40 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hou_state_land_01", "category": "housing", "doc": "memo", "sender": "min_housing",
|
||||
"title": { "en": "Release state land to developers", "fr": "Céder des terrains de l'État aux promoteurs" },
|
||||
"body": {
|
||||
"en": "The state owns vacant land in every major city. Selling it to developers would raise cash and homes, and invite accusations of favours.",
|
||||
"fr": "L'État possède des terrains vacants dans toutes les grandes villes. Les vendre aux promoteurs rapporterait de l'argent et des logements, et ferait naître des soupçons de faveurs."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Sell the land", "fr": "Vendre les terrains" },
|
||||
"effects": [
|
||||
{ "oneOff": -0.5 },
|
||||
{ "sector": "housing", "delta": 3, "days": 365 },
|
||||
{ "stat": "economy", "delta": 1, "days": 90 },
|
||||
{ "stat": "opinion", "delta": -2, "days": 40 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Keep the land", "fr": "Garder les terrains" },
|
||||
"effects": [{ "stat": "opinion", "delta": 1, "days": 30 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [
|
||||
"economy.json", "social.json", "health.json", "education.json", "security.json",
|
||||
"defense.json", "foreign.json", "industry.json", "transport.json", "housing.json",
|
||||
"environment.json", "justice.json", "culture.json", "crises.json"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
[
|
||||
{
|
||||
"id": "ind_privatise_energy_01", "category": "industry", "doc": "memo", "sender": "min_industry",
|
||||
"conditions": { "notFlags": ["energy_privatised"] },
|
||||
"title": { "en": "Privatise the energy industry?", "fr": "Privatiser le secteur de l'énergie ?" },
|
||||
"body": {
|
||||
"en": "Selling the state energy company would raise a large sum and bring in outside investment. Prices may rise, and the workers are already organising.",
|
||||
"fr": "Vendre l'entreprise publique d'énergie rapporterait une somme importante et attirerait des investisseurs. Les prix pourraient monter, et les salariés s'organisent déjà."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Privatise", "fr": "Privatiser" },
|
||||
"effects": [
|
||||
{ "oneOff": -1.5 },
|
||||
{ "stat": "economy", "delta": 3, "bias": true, "days": 365 },
|
||||
{ "stat": "inflation", "delta": 0.5, "bias": true, "days": 365 },
|
||||
{ "stat": "opinion", "delta": -4, "days": 60 }
|
||||
],
|
||||
"setFlags": ["energy_privatised"],
|
||||
"followUp": { "id": "ind_energy_strike_01", "inDays": 60 }
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Keep it public", "fr": "Garder le contrôle public" },
|
||||
"effects": [
|
||||
{ "sector": "industry", "delta": -3, "days": 120 },
|
||||
{ "stat": "opinion", "delta": 2, "days": 30 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ind_energy_strike_01", "category": "industry", "doc": "petition", "sender": "union", "followUpOnly": true,
|
||||
"title": { "en": "Energy workers walk out", "fr": "Les salariés de l'énergie débrayent" },
|
||||
"body": {
|
||||
"en": "Since the sale, the energy workers have lost job guarantees. They have walked out, and blackouts are being reported in several districts.",
|
||||
"fr": "Depuis la vente, les salariés de l'énergie ont perdu leurs garanties d'emploi. Ils ont débrayé et des coupures de courant sont signalées dans plusieurs quartiers."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Negotiate", "fr": "Négocier" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.3 },
|
||||
{ "stat": "stability", "delta": 2, "days": 30 },
|
||||
{ "stat": "economy", "delta": -1, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Order them back", "fr": "Ordonner la reprise" },
|
||||
"effects": [
|
||||
{ "stat": "order", "delta": -2, "days": 30 },
|
||||
{ "stat": "unrest", "delta": 4, "days": 40 },
|
||||
{ "stat": "civilRights", "delta": -2 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ind_green_subsidy_01", "category": "industry", "doc": "memo", "sender": "min_industry",
|
||||
"title": { "en": "Subsidies for green industry", "fr": "Subventions à l'industrie verte" },
|
||||
"body": {
|
||||
"en": "Manufacturers of solar panels and batteries ask for state support to get started. The Industry Ministry sees a growth sector; the Treasury sees a bill.",
|
||||
"fr": "Les fabricants de panneaux solaires et de batteries demandent un soutien de l'État pour démarrer. Le ministère de l'Industrie y voit un secteur d'avenir ; le Trésor, une facture."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Grant the subsidies", "fr": "Accorder les subventions" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.6 },
|
||||
{ "sector": "industry", "delta": 5, "days": 180 },
|
||||
{ "sector": "environment", "delta": 3, "days": 180 },
|
||||
{ "stat": "economy", "delta": 2, "bias": true, "days": 365 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Decline", "fr": "Décliner" },
|
||||
"effects": [{ "sector": "industry", "delta": -2, "days": 90 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ind_steel_bailout_01", "category": "industry", "doc": "letter", "sender": "min_industry",
|
||||
"title": { "en": "Bail out the steel mill", "fr": "Sauver l'aciérie" },
|
||||
"body": {
|
||||
"en": "The steel mill in the north is about to close, taking four thousand jobs with it. A rescue loan would keep the furnaces lit for now.",
|
||||
"fr": "L'aciérie du nord est sur le point de fermer, emportant quatre mille emplois. Un prêt de sauvetage maintiendrait les hauts fourneaux allumés pour l'instant."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Rescue it", "fr": "La sauver" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.5 },
|
||||
{ "stat": "unemployment", "delta": -0.2, "bias": true, "days": 365 },
|
||||
{ "stat": "economy", "delta": 1, "days": 60 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Let it close", "fr": "La laisser fermer" },
|
||||
"effects": [
|
||||
{ "stat": "unemployment", "delta": 0.4, "bias": true, "days": 365 },
|
||||
{ "stat": "unrest", "delta": 3, "days": 40 },
|
||||
{ "stat": "opinion", "delta": -3, "days": 40 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ind_special_zone_01", "category": "industry", "doc": "memo", "sender": "min_industry",
|
||||
"title": { "en": "A special economic zone", "fr": "Une zone économique spéciale" },
|
||||
"body": {
|
||||
"en": "Foreign investors would build factories in a tax-free zone with light regulation. Local workers would get jobs, and fewer protections.",
|
||||
"fr": "Des investisseurs étrangers construiraient des usines dans une zone franche à la réglementation allégée. Les travailleurs locaux auraient des emplois, et moins de protections."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Create the zone", "fr": "Créer la zone" },
|
||||
"effects": [
|
||||
{ "stat": "economy", "delta": 3, "bias": true, "days": 365 },
|
||||
{ "stat": "growth", "delta": 0.3, "bias": true, "days": 365 },
|
||||
{ "stat": "civilRights", "delta": -1 },
|
||||
{ "stat": "opinion", "delta": -2, "days": 40 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Decline", "fr": "Décliner" },
|
||||
"effects": [{ "stat": "economy", "delta": -1, "days": 60 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
[
|
||||
{
|
||||
"id": "jus_judicial_independence_01", "category": "justice", "doc": "memo", "sender": "min_justice",
|
||||
"title": { "en": "Guarantee judicial independence", "fr": "Garantir l'indépendance de la justice" },
|
||||
"body": {
|
||||
"en": "A reform would stop the government from appointing or dismissing judges. It would also stop it from correcting a ruling it dislikes.",
|
||||
"fr": "Une réforme empêcherait le gouvernement de nommer ou de révoquer les juges. Elle l'empêcherait aussi de corriger une décision qui lui déplaît."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Pass the reform", "fr": "Adopter la réforme" },
|
||||
"effects": [
|
||||
{ "stat": "civilRights", "delta": 4, "bias": true, "days": 365 },
|
||||
{ "sector": "justice", "delta": 4, "days": 180 },
|
||||
{ "stat": "stability", "delta": -1, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Keep control", "fr": "Garder la main" },
|
||||
"effects": [{ "stat": "civilRights", "delta": -2, "bias": true, "days": 365 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jus_fake_news_law_01", "category": "justice", "doc": "memo", "sender": "press_office",
|
||||
"title": { "en": "A law against “fake news”", "fr": "Une loi contre les « fausses informations »" },
|
||||
"body": {
|
||||
"en": "The Press Office wants to criminalise the spread of false reports. Journalists point out that the definition of false will be set by the government.",
|
||||
"fr": "Le service de presse veut pénaliser la diffusion de fausses nouvelles. Les journalistes font remarquer que c'est le gouvernement qui définira ce qui est faux."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Pass the law", "fr": "Voter la loi" },
|
||||
"effects": [
|
||||
{ "stat": "stability", "delta": 3, "bias": true, "days": 365 },
|
||||
{ "stat": "civilRights", "delta": -5, "bias": true, "days": 365 },
|
||||
{ "stat": "softPower", "delta": -3, "days": 90 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Drop the idea", "fr": "Abandonner l'idée" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": -1, "days": 30 },
|
||||
{ "stat": "stability", "delta": -2, "days": 40 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jus_prisons_01", "category": "justice", "doc": "report", "sender": "min_justice",
|
||||
"title": { "en": "Overcrowded prisons", "fr": "Des prisons surpeuplées" },
|
||||
"body": {
|
||||
"en": "The prisons hold a third more inmates than they were built for. Releasing non-violent offenders would relieve them. Building new cells would cost money and take years.",
|
||||
"fr": "Les prisons accueillent un tiers de détenus de plus que prévu. Libérer les condamnés non violents les soulagerait. Construire de nouvelles cellules coûterait cher et prendrait des années."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Release non-violent inmates", "fr": "Libérer les non-violents" },
|
||||
"effects": [
|
||||
{ "extraSpending": -0.1 },
|
||||
{ "stat": "order", "delta": -2, "days": 60 },
|
||||
{ "stat": "civilRights", "delta": 2 },
|
||||
{ "stat": "opinion", "delta": -2, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Build new prisons", "fr": "Construire de nouvelles prisons" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.4 },
|
||||
{ "sector": "justice", "delta": 3, "days": 120 },
|
||||
{ "stat": "opinion", "delta": 1, "days": 30 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jus_anticorruption_01", "category": "justice", "doc": "memo", "sender": "min_justice",
|
||||
"title": { "en": "An anti-corruption agency", "fr": "Une agence anticorruption" },
|
||||
"body": {
|
||||
"en": "An independent agency would investigate bribery in ministries and state contracts. Some of the people it would investigate sit close to you.",
|
||||
"fr": "Une agence indépendante enquêterait sur la corruption dans les ministères et les marchés publics. Certaines des personnes visées sont proches de vous."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Create the agency", "fr": "Créer l'agence" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.2 },
|
||||
{ "sector": "justice", "delta": 4, "days": 180 },
|
||||
{ "stat": "opinion", "delta": 4, "days": 60 },
|
||||
{ "stat": "stability", "delta": -2, "days": 40 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": -3, "days": 40 },
|
||||
{ "stat": "softPower", "delta": -1, "days": 60 }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,118 @@
|
||||
[
|
||||
{
|
||||
"id": "sec_crackdown_01", "category": "security", "doc": "memo", "sender": "min_interior",
|
||||
"conditions": { "min": { "unrest": 35 } },
|
||||
"title": { "en": "Send police to the protesters?", "fr": "Envoyer la police contre les manifestants ?" },
|
||||
"body": {
|
||||
"en": "Thousands have occupied the central square for three days. The Interior Ministry can clear it by force tonight. The alternative is to wait and hope it burns out.",
|
||||
"fr": "Des milliers de personnes occupent la place centrale depuis trois jours. Le ministère de l'Intérieur peut la faire évacuer par la force ce soir. L'autre option est d'attendre que le mouvement s'essouffle."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Clear the square", "fr": "Évacuer la place" },
|
||||
"effects": [
|
||||
{ "stat": "order", "delta": 6, "days": 14 },
|
||||
{ "stat": "unrest", "delta": -6, "days": 30 },
|
||||
{ "stat": "civilRights", "delta": -5 },
|
||||
{ "stat": "softPower", "delta": -4, "days": 30 }
|
||||
],
|
||||
"setFlags": ["crackdown"],
|
||||
"followUp": { "id": "sec_backlash_01", "inDays": 30 }
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Let them march", "fr": "Les laisser manifester" },
|
||||
"effects": [
|
||||
{ "stat": "order", "delta": -4, "days": 14 },
|
||||
{ "stat": "unrest", "delta": 4, "days": 30 },
|
||||
{ "stat": "opinion", "delta": 2, "days": 30 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sec_backlash_01", "category": "security", "doc": "report", "sender": "press_office", "followUpOnly": true,
|
||||
"title": { "en": "Rights report condemns the crackdown", "fr": "Un rapport condamne la répression" },
|
||||
"body": {
|
||||
"en": "An international rights group has published testimonies from the night the square was cleared. Foreign editors are asking for a response.",
|
||||
"fr": "Une organisation internationale de défense des droits a publié des témoignages sur la nuit de l'évacuation de la place. Les rédactions étrangères demandent une réaction."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Open an inquiry", "fr": "Ouvrir une enquête" },
|
||||
"effects": [
|
||||
{ "stat": "civilRights", "delta": 3 },
|
||||
{ "stat": "softPower", "delta": 3, "days": 60 },
|
||||
{ "stat": "order", "delta": -1, "days": 30 },
|
||||
{ "stat": "stability", "delta": -1, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Dismiss the report", "fr": "Rejeter le rapport" },
|
||||
"effects": [
|
||||
{ "stat": "softPower", "delta": -4, "days": 60 },
|
||||
{ "stat": "opinion", "delta": -2, "days": 30 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sec_cctv_01", "category": "security", "doc": "memo", "sender": "min_interior",
|
||||
"title": { "en": "Nationwide CCTV network", "fr": "Un réseau national de vidéosurveillance" },
|
||||
"body": {
|
||||
"en": "A camera network covering every major street would deter crime and help solve it. Privacy lawyers are already preparing their case.",
|
||||
"fr": "Un réseau de caméras couvrant chaque grande rue dissuaderait la criminalité et aiderait à la résoudre. Les avocats de la vie privée préparent déjà leur dossier."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Install the network", "fr": "Installer le réseau" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.5 },
|
||||
{ "stat": "order", "delta": 3, "bias": true, "days": 365 },
|
||||
{ "stat": "civilRights", "delta": -3, "bias": true, "days": 365 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||
"effects": [{ "stat": "order", "delta": -1, "days": 30 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sec_riot_gear_01", "category": "security", "doc": "memo", "sender": "min_interior",
|
||||
"title": { "en": "Armoured vehicles for riot police", "fr": "Véhicules blindés pour les forces anti-émeute" },
|
||||
"body": {
|
||||
"en": "The riot police ask for armoured vehicles and new equipment. The images of them on the streets may unsettle the public.",
|
||||
"fr": "Les forces anti-émeute demandent des véhicules blindés et du nouveau matériel. Leur image dans les rues pourrait inquiéter la population."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Approve the purchase", "fr": "Approuver l'achat" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.3 },
|
||||
{ "sector": "police", "delta": 5, "days": 60 },
|
||||
{ "stat": "civilRights", "delta": -2 },
|
||||
{ "stat": "softPower", "delta": -2, "days": 60 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||
"effects": [{ "sector": "police", "delta": -2, "days": 60 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sec_gang_amnesty_01", "category": "security", "doc": "letter", "sender": "mayor",
|
||||
"title": { "en": "Offer the street gangs an amnesty", "fr": "Proposer une amnistie aux gangs de rue" },
|
||||
"body": {
|
||||
"en": "The mayor has brokered talks with gang leaders: weapons handed in against a pardon for minor offences. Many will see it as surrender.",
|
||||
"fr": "Le maire a organisé des pourparlers avec les chefs de gangs : des armes rendues contre une grâce pour les délits mineurs. Beaucoup y verront une capitulation."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Grant the amnesty", "fr": "Accorder l'amnistie" },
|
||||
"effects": [
|
||||
{ "stat": "order", "delta": 3, "days": 90 },
|
||||
{ "stat": "unrest", "delta": -2, "days": 60 },
|
||||
{ "stat": "opinion", "delta": -3, "days": 40 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||
"effects": [
|
||||
{ "stat": "order", "delta": -2, "days": 60 },
|
||||
{ "stat": "opinion", "delta": 2, "days": 30 }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
[
|
||||
{
|
||||
"id": "soc_pension_age_01", "category": "social", "doc": "memo", "sender": "min_labour",
|
||||
"conditions": { "dayMin": 60 },
|
||||
"title": { "en": "Raise the retirement age?", "fr": "Reculer l'âge de la retraite ?" },
|
||||
"body": {
|
||||
"en": "The pension fund is running a growing gap. The Labour Ministry suggests raising the retirement age by two years. Every union has already said no.",
|
||||
"fr": "La caisse des retraites accuse un déficit croissant. Le ministère du Travail suggère de repousser l'âge de départ de deux ans. Tous les syndicats ont déjà dit non."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Raise the age", "fr": "Reculer l'âge" },
|
||||
"effects": [
|
||||
{ "extraSpending": -0.5 },
|
||||
{ "stat": "opinion", "delta": -5, "days": 60 },
|
||||
{ "stat": "unrest", "delta": 4, "days": 60 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Leave it as is", "fr": "Ne rien changer" },
|
||||
"effects": [{ "extraSpending": 0.3 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "soc_childcare_01", "category": "social", "doc": "memo", "sender": "min_labour",
|
||||
"title": { "en": "Universal childcare", "fr": "Garde d'enfants universelle" },
|
||||
"body": {
|
||||
"en": "Free public childcare would let thousands of parents return to work. It would also add a permanent line to the budget.",
|
||||
"fr": "Une garde d'enfants publique et gratuite permettrait à des milliers de parents de retourner travailler. Elle ajouterait aussi une ligne permanente au budget."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Fund it", "fr": "La financer" },
|
||||
"effects": [
|
||||
{ "extraSpending": 0.6 },
|
||||
{ "stat": "opinion", "delta": 4, "days": 45 },
|
||||
{ "stat": "unemployment", "delta": -0.3, "bias": true, "days": 365 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Decline", "fr": "Décliner" },
|
||||
"effects": [{ "stat": "opinion", "delta": -2, "days": 30 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "soc_port_strike_01", "category": "social", "doc": "petition", "sender": "union",
|
||||
"conditions": { "dayMin": 30 },
|
||||
"title": { "en": "Port workers demand a raise", "fr": "Les dockers réclament une augmentation" },
|
||||
"body": {
|
||||
"en": "Dockworkers threaten to stop loading ships on Monday unless wages are raised. The harbour master reports a queue of cargo vessels already waiting.",
|
||||
"fr": "Les dockers menacent de cesser de charger les navires lundi si les salaires ne sont pas relevés. La capitainerie signale déjà une file de cargos en attente."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Grant the raise", "fr": "Accorder la hausse" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.2 },
|
||||
{ "stat": "stability", "delta": 2, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Hold the line", "fr": "Tenir bon" },
|
||||
"effects": [
|
||||
{ "stat": "unrest", "delta": 4, "days": 30 },
|
||||
{ "stat": "economy", "delta": -2, "days": 30 },
|
||||
{ "stat": "order", "delta": -2, "days": 30 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "soc_food_subsidy_01", "category": "social", "doc": "memo", "sender": "min_labour",
|
||||
"conditions": { "min": { "inflation": 4 } },
|
||||
"title": { "en": "Subsidise bread and fuel", "fr": "Subventionner le pain et le carburant" },
|
||||
"body": {
|
||||
"en": "Prices are climbing faster than wages. A subsidy on bread and fuel would ease the pressure on poorer households, at the cost of the deficit.",
|
||||
"fr": "Les prix montent plus vite que les salaires. Une subvention sur le pain et le carburant soulagerait les ménages modestes, au prix du déficit."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Subsidise", "fr": "Subventionner" },
|
||||
"effects": [
|
||||
{ "extraSpending": 0.5 },
|
||||
{ "stat": "opinion", "delta": 3, "days": 30 },
|
||||
{ "stat": "inflation", "delta": -0.5, "bias": true, "days": 120 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Let prices run", "fr": "Laisser les prix filer" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": -4, "days": 40 },
|
||||
{ "stat": "unrest", "delta": 3, "days": 40 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "soc_shelters_01", "category": "social", "doc": "letter", "sender": "mayor",
|
||||
"title": { "en": "Winter shelters for the homeless", "fr": "Des abris d'hiver pour les sans-abri" },
|
||||
"body": {
|
||||
"en": "The mayor of the capital asks for emergency funds to open heated shelters before the first frost. Without them, the city expects a hard winter.",
|
||||
"fr": "Le maire de la capitale demande des fonds d'urgence pour ouvrir des abris chauffés avant les premières gelées. Sans eux, la ville s'attend à un hiver difficile."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Release the funds", "fr": "Débloquer les fonds" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.3 },
|
||||
{ "stat": "opinion", "delta": 2, "days": 30 },
|
||||
{ "stat": "order", "delta": 1, "days": 30 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Not now", "fr": "Pas maintenant" },
|
||||
"effects": [
|
||||
{ "stat": "opinion", "delta": -1, "days": 30 },
|
||||
{ "stat": "unrest", "delta": 1, "days": 30 }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
[
|
||||
{
|
||||
"id": "trn_high_speed_rail_01", "category": "transport", "doc": "memo", "sender": "min_transport",
|
||||
"title": { "en": "A high-speed rail link", "fr": "Une ligne ferroviaire à grande vitesse" },
|
||||
"body": {
|
||||
"en": "A high-speed line between the capital and the southern port would tie the country together. It is the biggest construction project in a generation, and the bill matches.",
|
||||
"fr": "Une ligne à grande vitesse entre la capitale et le port du sud réunirait le pays. C'est le plus grand chantier depuis une génération, et la facture est à l'avenant."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Build it", "fr": "La construire" },
|
||||
"effects": [
|
||||
{ "oneOff": 1.2 },
|
||||
{ "sector": "transport", "delta": 6, "days": 365 },
|
||||
{ "stat": "economy", "delta": 2, "bias": true, "days": 730 },
|
||||
{ "stat": "opinion", "delta": 2, "days": 45 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Shelve the project", "fr": "Mettre le projet de côté" },
|
||||
"effects": [{ "sector": "transport", "delta": -2, "days": 180 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trn_free_buses_01", "category": "transport", "doc": "petition", "sender": "mayor",
|
||||
"title": { "en": "Free buses in the capital", "fr": "Bus gratuits dans la capitale" },
|
||||
"body": {
|
||||
"en": "The city asks the government to cover bus fares for residents. Congestion would ease, and so would household budgets.",
|
||||
"fr": "La ville demande au gouvernement de prendre en charge les tickets de bus des habitants. Les embouteillages diminueraient, ainsi que les budgets des ménages."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Cover the fares", "fr": "Prendre en charge" },
|
||||
"effects": [
|
||||
{ "extraSpending": 0.2 },
|
||||
{ "stat": "opinion", "delta": 3, "days": 40 },
|
||||
{ "sector": "transport", "delta": 3, "days": 120 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||
"effects": [{ "sector": "transport", "delta": -2, "days": 90 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trn_port_expansion_01", "category": "transport", "doc": "memo", "sender": "min_transport",
|
||||
"title": { "en": "Expand the main port", "fr": "Agrandir le grand port" },
|
||||
"body": {
|
||||
"en": "Cargo volumes are outgrowing the main port. New quays and cranes would cut waiting times and attract shipping lines.",
|
||||
"fr": "Les volumes de fret dépassent les capacités du grand port. De nouveaux quais et des grues réduiraient les temps d'attente et attireraient les compagnies maritimes."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Fund the expansion", "fr": "Financer l'extension" },
|
||||
"effects": [
|
||||
{ "oneOff": 0.8 },
|
||||
{ "stat": "economy", "delta": 2, "bias": true, "days": 540 },
|
||||
{ "stat": "growth", "delta": 0.2, "bias": true, "days": 540 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Postpone", "fr": "Reporter" },
|
||||
"effects": [{ "stat": "economy", "delta": -1, "days": 60 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trn_road_tolls_01", "category": "transport", "doc": "memo", "sender": "min_transport",
|
||||
"title": { "en": "Introduce road tolls", "fr": "Instaurer des péages routiers" },
|
||||
"body": {
|
||||
"en": "Tolls on the main motorways would pay for maintenance and a new revenue line. Drivers will not like it.",
|
||||
"fr": "Des péages sur les grandes autoroutes financeraient l'entretien et créeraient une nouvelle source de recettes. Les automobilistes n'apprécieront pas."
|
||||
},
|
||||
"yes": {
|
||||
"label": { "en": "Introduce the tolls", "fr": "Instaurer les péages" },
|
||||
"effects": [
|
||||
{ "revenue": "other", "delta": 0.3 },
|
||||
{ "stat": "opinion", "delta": -3, "days": 45 },
|
||||
{ "sector": "transport", "delta": 2, "days": 120 }
|
||||
]
|
||||
},
|
||||
"no": {
|
||||
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||
"effects": [{ "sector": "infrastructure", "delta": -3, "days": 180 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"min_finance": { "seal": "EV", "name": { "en": "Elena Voss", "fr": "Elena Voss" }, "role": { "en": "Minister of Finance", "fr": "Ministre des Finances" } },
|
||||
"min_interior": { "seal": "TB", "name": { "en": "Tomas Brandt", "fr": "Tomas Brandt" }, "role": { "en": "Minister of the Interior", "fr": "Ministre de l'Intérieur" } },
|
||||
"min_defense": { "seal": "AH", "name": { "en": "Anders Holm", "fr": "Anders Holm" }, "role": { "en": "Minister of Defence", "fr": "Ministre de la Défense" } },
|
||||
"min_health": { "seal": "IC", "name": { "en": "Irina Castellan", "fr": "Irina Castellan" }, "role": { "en": "Minister of Health", "fr": "Ministre de la Santé" } },
|
||||
"min_education": { "seal": "LM", "name": { "en": "Leon Marek", "fr": "Leon Marek" }, "role": { "en": "Minister of Education", "fr": "Ministre de l'Éducation" } },
|
||||
"min_foreign": { "seal": "SA", "name": { "en": "Sofia Alder", "fr": "Sofia Alder" }, "role": { "en": "Minister of Foreign Affairs", "fr": "Ministre des Affaires étrangères" } },
|
||||
"min_justice": { "seal": "RQ", "name": { "en": "Rafael Quist", "fr": "Rafael Quist" }, "role": { "en": "Minister of Justice", "fr": "Ministre de la Justice" } },
|
||||
"min_industry": { "seal": "GL", "name": { "en": "Greta Lindqvist", "fr": "Greta Lindqvist" }, "role": { "en": "Minister of Industry and Energy", "fr": "Ministre de l'Industrie et de l'Énergie" } },
|
||||
"min_transport": { "seal": "PN", "name": { "en": "Pavel Novak", "fr": "Pavel Novak" }, "role": { "en": "Minister of Transport", "fr": "Ministre des Transports" } },
|
||||
"min_housing": { "seal": "CB", "name": { "en": "Clara Bellamy", "fr": "Clara Bellamy" }, "role": { "en": "Minister of Housing", "fr": "Ministre du Logement" } },
|
||||
"min_environment": { "seal": "HS", "name": { "en": "Henrik Saal", "fr": "Henrik Saal" }, "role": { "en": "Minister of the Environment", "fr": "Ministre de l'Environnement" } },
|
||||
"min_culture": { "seal": "OM", "name": { "en": "Odile Marchand", "fr": "Odile Marchand" }, "role": { "en": "Minister of Culture", "fr": "Ministre de la Culture" } },
|
||||
"min_labour": { "seal": "VR", "name": { "en": "Viktor Rask", "fr": "Viktor Rask" }, "role": { "en": "Minister of Labour and Social Affairs", "fr": "Ministre du Travail et des Affaires sociales" } },
|
||||
"central_bank": { "seal": "DF", "name": { "en": "Dorian Faulk", "fr": "Dorian Faulk" }, "role": { "en": "Governor of the Central Bank", "fr": "Gouverneur de la Banque centrale" } },
|
||||
"press_office": { "seal": "JE", "name": { "en": "Jonas Ekberg", "fr": "Jonas Ekberg" }, "role": { "en": "Press Secretary", "fr": "Porte-parole du gouvernement" } },
|
||||
"general": { "seal": "KV", "name": { "en": "Katarina Voight", "fr": "Katarina Voight" }, "role": { "en": "Chief of the General Staff", "fr": "Chef d'état-major" } },
|
||||
"intelligence": { "seal": "LO", "name": { "en": "Lucas Orme", "fr": "Lucas Orme" }, "role": { "en": "Director of Intelligence", "fr": "Directeur du renseignement" } },
|
||||
"mayor": { "seal": "AR", "name": { "en": "Anselm Roth", "fr": "Anselm Roth" }, "role": { "en": "Mayor of the capital", "fr": "Maire de la capitale" } },
|
||||
"union": { "seal": "NL", "name": { "en": "National Labour Confederation", "fr": "Confédération nationale du travail" }, "role": { "en": "Trade union", "fr": "Syndicat" } },
|
||||
"rating_agency": { "seal": "HP", "name": { "en": "Hallam & Pryce", "fr": "Hallam & Pryce" }, "role": { "en": "Credit rating agency", "fr": "Agence de notation" } }
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"over": {
|
||||
"term": {
|
||||
"h": { "en": "Five years on: the verdict", "fr": "Cinq ans après : le verdict" },
|
||||
"d": { "en": "A term ends. The country weighs what it was given and what was done with it.", "fr": "Un mandat s'achève. Le pays pèse ce qu'on lui a laissé et ce qu'on en a fait." }
|
||||
},
|
||||
"revolution": {
|
||||
"h": { "en": "GOVERNMENT TOPPLED BY UPRISING", "fr": "LE GOUVERNEMENT RENVERSÉ PAR LE SOULÈVEMENT" },
|
||||
"d": { "en": "Crowds storm the ministries as the head of state flees the capital.", "fr": "La foule prend d'assaut les ministères alors que le chef de l'État fuit la capitale." }
|
||||
},
|
||||
"coup": {
|
||||
"h": { "en": "ARMY SEIZES POWER", "fr": "L'ARMÉE PREND LE POUVOIR" },
|
||||
"d": { "en": "Tanks in the squares and marches on the radio: the government has fallen.", "fr": "Des chars sur les places, des marches à la radio : le gouvernement est tombé." }
|
||||
},
|
||||
"default": {
|
||||
"h": { "en": "COUNTRY DEFAULTS ON ITS DEBT", "fr": "LE PAYS FAIT DÉFAUT SUR SA DETTE" },
|
||||
"d": { "en": "Lenders walk away, banks close their doors and the government loses the confidence of the markets.", "fr": "Les prêteurs se retirent, les banques ferment leurs portes et le gouvernement perd la confiance des marchés." }
|
||||
},
|
||||
"impeachment": {
|
||||
"h": { "en": "PARLIAMENT VOTES NO CONFIDENCE", "fr": "LE PARLEMENT VOTE LA DÉFIANCE" },
|
||||
"d": { "en": "The motion passes by a wide margin. The head of state is removed from office.", "fr": "La motion passe à une large majorité. Le chef de l'État est destitué." }
|
||||
}
|
||||
},
|
||||
|
||||
"alerts": {
|
||||
"revolution": {
|
||||
"title": { "en": "Crowds at the gates", "fr": "La foule aux portes" },
|
||||
"body": {
|
||||
"en": "SECURITY SERVICES REPORT MASS CROWDS AROUND THE MINISTRIES. THE CAPITAL IS ON THE EDGE OF REVOLT. WITHOUT RELIEF THE GOVERNMENT MAY NOT HOLD. STOP.",
|
||||
"fr": "LES SERVICES DE SECURITE SIGNALENT DES FOULES IMMENSES AUTOUR DES MINISTERES. LA CAPITALE EST AU BORD DE LA REVOLTE. SANS APAISEMENT, LE GOUVERNEMENT NE TIENDRA PAS. STOP."
|
||||
}
|
||||
},
|
||||
"coup": {
|
||||
"title": { "en": "The generals are restless", "fr": "Les généraux s'impatientent" },
|
||||
"body": {
|
||||
"en": "INTELLIGENCE WARNS THAT SENIOR OFFICERS NO LONGER BELIEVE IN THE GOVERNMENT. UNITS ARE MOVING TOWARD THE CAPITAL. STOP.",
|
||||
"fr": "LE RENSEIGNEMENT ALERTE : LES OFFICIERS SUPERIEURS NE CROIENT PLUS AU GOUVERNEMENT. DES UNITES SE DEPLACENT VERS LA CAPITALE. STOP."
|
||||
}
|
||||
},
|
||||
"impeachment": {
|
||||
"title": { "en": "A motion of no confidence", "fr": "Une motion de défiance" },
|
||||
"body": {
|
||||
"en": "OPPOSITION DEPUTIES FILE A MOTION OF NO CONFIDENCE. YOUR MAJORITY IS DISSOLVING. SIXTY DAYS REMAIN TO RESTORE TRUST. STOP.",
|
||||
"fr": "LES DEPUTES D'OPPOSITION DEPOSENT UNE MOTION DE DEFIANCE. VOTRE MAJORITE SE DISSOUT. SOIXANTE JOURS POUR RETABLIR LA CONFIANCE. STOP."
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"title": { "en": "Lenders pull back", "fr": "Les prêteurs se retirent" },
|
||||
"body": {
|
||||
"en": "BOND AUCTIONS FAIL. LENDERS REFUSE NEW LOANS AT ANY REASONABLE RATE. THE TREASURY CANNOT BORROW MUCH LONGER. STOP.",
|
||||
"fr": "LES ADJUDICATIONS D'OBLIGATIONS ECHOUENT. LES PRETEURS REFUSENT DE NOUVEAUX PRETS A TAUX RAISONNABLE. LE TRESOR NE POURRA PLUS EMPRUNTER LONGTEMPS. STOP."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"titles": [
|
||||
{
|
||||
"id": "iron_chancellor",
|
||||
"conditions": { "min": { "order": 66, "score": 45 }, "max": { "civilRights": 45 } },
|
||||
"title": { "en": "The Iron Chancellor", "fr": { "he": "Chancelier de fer", "she": "Chancelière de fer", "they": "Chancelier·ère de fer" } },
|
||||
"verdict": {
|
||||
"en": "{Sub} {has} kept the streets quiet and the barracks loyal, and the price was every freedom that stood in the way. Order was never in doubt. Consent was.",
|
||||
"fr": "{Sub} a gardé les rues calmes et les casernes loyales, au prix de toutes les libertés qui gênaient. L'ordre n'a jamais fait de doute. Le consentement, si."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "soldier_ruler",
|
||||
"conditions": { "min": { "hardPower": 62, "armyLoyalty": 72 } },
|
||||
"title": { "en": "The Soldier-Ruler", "fr": { "he": "Chef de guerre", "she": "Cheffe de guerre", "they": "Chef·fe de guerre" } },
|
||||
"verdict": {
|
||||
"en": "Under {pos} rule the army grew strong and stayed faithful, and the country learned to look to its generals first and its voters second.",
|
||||
"fr": "Sous ce mandat l'armée est devenue puissante et fidèle, et le pays a appris à regarder ses généraux avant ses électeurs."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "reformer",
|
||||
"conditions": { "min": { "civilRights": 58, "growth": 2.2, "score": 58 } },
|
||||
"title": { "en": "The Reformer", "fr": { "he": "Réformateur", "she": "Réformatrice", "they": "Réformateur·rice" } },
|
||||
"verdict": {
|
||||
"en": "{Sub} {was} patient, and it paid: wages rose, courts grew stronger and few people were afraid to say what they thought.",
|
||||
"fr": "{Sub} a été patient{e}, et cela a payé : les salaires ont monté, les tribunaux se sont renforcés et peu de gens avaient peur de dire ce qu'ils pensaient."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "statesman",
|
||||
"conditions": { "min": { "softPower": 56, "stability": 58, "score": 55 } },
|
||||
"title": { "en": "The Statesman", "fr": { "he": "Homme d'État", "she": "Femme d'État", "they": "Figure d'État" } },
|
||||
"verdict": {
|
||||
"en": "Abroad, {pos} name opened doors. At home, {sub} {has} left institutions steadier than {sub} found them.",
|
||||
"fr": "À l'étranger, ce nom ouvrait des portes. Chez nous, les institutions sont plus solides qu'à son arrivée."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "champion",
|
||||
"conditions": { "min": { "opinion": 58 }, "max": { "unrest": 28 } },
|
||||
"title": { "en": "The People's Champion", "fr": { "he": "Champion du peuple", "she": "Championne du peuple", "they": "Champion·ne du peuple" } },
|
||||
"verdict": {
|
||||
"en": "The crowds cheered, the polls agreed, and for five years the palace and the street seemed to want the same thing.",
|
||||
"fr": "Les foules ont acclamé, les sondages ont suivi, et pendant cinq ans le palais et la rue ont semblé vouloir la même chose."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "builder",
|
||||
"conditions": { "min": { "economy": 60, "growth": 2.4 } },
|
||||
"title": { "en": "The Builder", "fr": { "he": "Bâtisseur", "she": "Bâtisseuse", "they": "Bâtisseur·se" } },
|
||||
"verdict": {
|
||||
"en": "Cranes over the capital, ports full of cargo and a growth rate the neighbours envied. {Sub} {was} remembered for what got built.",
|
||||
"fr": "Des grues au-dessus de la capitale, des ports pleins de cargos et une croissance que les voisins enviaient. On se souviendra de ce qui a été construit."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bankrupt",
|
||||
"conditions": { "min": { "debt": 130 } },
|
||||
"title": { "en": "The Bankrupt", "fr": { "he": "Banqueroutier", "she": "Banqueroutière", "they": "Banqueroutier·ère" } },
|
||||
"verdict": {
|
||||
"en": "The books never balanced, and the bill was passed to those who came next. {Sub} {was} generous with money the country did not have.",
|
||||
"fr": "Les comptes ne sont jamais revenus à l'équilibre et la facture est passée à ceux qui suivent. {Sub} a dépensé sans compter un argent que le pays n'avait pas."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "forgotten",
|
||||
"conditions": { "max": { "score": 42 } },
|
||||
"title": { "en": "The Forgotten", "fr": { "he": "Oublié", "she": "Oubliée", "they": "Oublié·e" } },
|
||||
"verdict": {
|
||||
"en": "Five years passed, and little of it was worth a history book. {Sub} will be remembered mostly by those who worked in the palace.",
|
||||
"fr": "Cinq ans ont passé, et peu de choses méritent un manuel d'histoire. On se souviendra surtout de {tonic} dans les couloirs du palais."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "caretaker",
|
||||
"title": { "en": "The Caretaker", "fr": { "he": "Gestionnaire", "she": "Gestionnaire", "they": "Gestionnaire" } },
|
||||
"verdict": {
|
||||
"en": "{Sub} kept the lights on. No one was thrilled, no one was ruined, and the next government inherited a country much as it was.",
|
||||
"fr": "{Sub} a gardé les lumières allumées. Personne n'a été enthousiasmé, personne n'a été ruiné, et le gouvernement suivant a hérité d'un pays à peu près inchangé."
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"id": "deposed",
|
||||
"over": "revolution",
|
||||
"title": { "en": "The Fallen Leader", "fr": { "he": "Dirigeant déchu", "she": "Dirigeante déchue", "they": "Dirigeant·e déchu·e" } },
|
||||
"verdict": {
|
||||
"en": "The crowds reached the palace before the loyal troops did. {Sub} left by the back door, and the portraits came down by morning.",
|
||||
"fr": "Les foules ont atteint le palais avant les troupes loyales. La sortie s'est faite par la porte de service, et les portraits étaient décrochés au matin."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ousted",
|
||||
"over": "coup",
|
||||
"title": { "en": "The Ousted", "fr": { "he": "Évincé", "she": "Évincée", "they": "Évincé·e" } },
|
||||
"verdict": {
|
||||
"en": "At dawn the radio played marches and the generals announced a new order. {Sub} {was} escorted from the palace with {pos} papers.",
|
||||
"fr": "À l'aube, la radio a diffusé des marches militaires et les généraux ont annoncé un nouvel ordre. {Sub} a été escorté{e} hors du palais."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "defaulted",
|
||||
"over": "default",
|
||||
"title": { "en": "The Bankrupt", "fr": { "he": "Banqueroutier", "she": "Banqueroutière", "they": "Banqueroutier·ère" } },
|
||||
"verdict": {
|
||||
"en": "The creditors stopped lending and the Treasury ran dry. The country defaulted, and {pos} signature was on the last budget.",
|
||||
"fr": "Les créanciers ont cessé de prêter et le Trésor s'est vidé. Le pays a fait défaut, et le dernier budget porte sa signature."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "impeached",
|
||||
"over": "impeachment",
|
||||
"title": { "en": "The Impeached", "fr": { "he": "Destitué", "she": "Destituée", "they": "Destitué·e" } },
|
||||
"verdict": {
|
||||
"en": "Parliament voted, and not one deputy rose to speak for {obj}. The term ended in a half-empty chamber.",
|
||||
"fr": "Le Parlement a voté, et aucun député ne s'est levé pour prendre la parole en faveur de {tonic}. Le mandat s'est achevé dans un hémicycle à moitié vide."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"name": { "en": "The Daily Herald", "fr": "Le Courrier du Jour" },
|
||||
|
||||
"intro": [
|
||||
{ "h": { "en": "A new government takes office", "fr": "Un nouveau gouvernement prend ses fonctions" },
|
||||
"d": { "en": "{country} awaits its first decisions.", "fr": "{country} attend ses premières décisions." } }
|
||||
],
|
||||
|
||||
"quiet": [
|
||||
{ "h": { "en": "A quiet week in the capital", "fr": "Semaine calme dans la capitale" },
|
||||
"d": { "en": "Ministers report nothing of note.", "fr": "Les ministres n'ont rien à signaler." } },
|
||||
{ "h": { "en": "Markets and streets stay calm", "fr": "Les marchés et les rues restent calmes" },
|
||||
"d": { "en": "Analysts expect no change in the weeks ahead.", "fr": "Les analystes n'attendent pas de changement dans les semaines à venir." } }
|
||||
],
|
||||
|
||||
"ind": {
|
||||
"order": {
|
||||
"up": [
|
||||
{ "h": { "en": "Streets calmer as order improves", "fr": "Les rues s'apaisent, l'ordre se rétablit" },
|
||||
"d": { "en": "Police report fewer incidents across the country.", "fr": "La police signale moins d'incidents dans tout le pays." } },
|
||||
{ "h": { "en": "Crime figures fall for a month", "fr": "La criminalité recule depuis un mois" },
|
||||
"d": { "en": "The interior ministry credits tighter patrols.", "fr": "Le ministère de l'Intérieur salue des patrouilles renforcées." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Disorder spreads in the cities", "fr": "Le désordre gagne les villes" },
|
||||
"d": { "en": "Officials admit control is slipping.", "fr": "Les autorités admettent perdre le contrôle." } },
|
||||
{ "h": { "en": "Lawlessness on the rise", "fr": "L'insécurité progresse" },
|
||||
"d": { "en": "Shopkeepers demand protection.", "fr": "Les commerçants réclament une protection." } }
|
||||
]
|
||||
},
|
||||
"stability": {
|
||||
"up": [
|
||||
{ "h": { "en": "Government steadies after turbulent weeks", "fr": "Le gouvernement se reprend après des semaines agitées" },
|
||||
"d": { "en": "Coalition partners close ranks.", "fr": "Les partenaires de la coalition resserrent les rangs." } },
|
||||
{ "h": { "en": "Political climate cools", "fr": "Le climat politique se détend" },
|
||||
"d": { "en": "Analysts note fewer cabinet rifts.", "fr": "Les analystes notent moins de divisions au gouvernement." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Cracks show in the ruling circle", "fr": "Des fissures dans le cercle dirigeant" },
|
||||
"d": { "en": "Rumours of a reshuffle grow.", "fr": "Les rumeurs de remaniement se multiplient." } },
|
||||
{ "h": { "en": "Instability worries diplomats", "fr": "L'instabilité inquiète les diplomates" },
|
||||
"d": { "en": "Embassies advise caution.", "fr": "Les ambassades recommandent la prudence." } }
|
||||
]
|
||||
},
|
||||
"civilRights": {
|
||||
"up": [
|
||||
{ "h": { "en": "Freedoms widen, say rights groups", "fr": "Les libertés s'élargissent, selon les ONG" },
|
||||
"d": { "en": "Press and assembly face fewer limits.", "fr": "La presse et les rassemblements sont moins entravés." } },
|
||||
{ "h": { "en": "Courts restore protections", "fr": "Les tribunaux rétablissent des protections" },
|
||||
"d": { "en": "Lawyers hail landmark rulings.", "fr": "Les avocats saluent des décisions historiques." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Rights groups sound the alarm", "fr": "Les ONG tirent la sonnette d'alarme" },
|
||||
"d": { "en": "Curbs on press and assembly tighten.", "fr": "Les restrictions sur la presse et les rassemblements se durcissent." } },
|
||||
{ "h": { "en": "Civil liberties under pressure", "fr": "Les libertés civiles sous pression" },
|
||||
"d": { "en": "Critics speak of a creeping crackdown.", "fr": "Les critiques dénoncent une répression rampante." } }
|
||||
]
|
||||
},
|
||||
"opinion": {
|
||||
"up": [
|
||||
{ "h": { "en": "Public mood brightens", "fr": "L'humeur du public s'éclaircit" },
|
||||
"d": { "en": "Polls show growing approval of the head of state.", "fr": "Les sondages montrent une approbation croissante du chef de l'État." } },
|
||||
{ "h": { "en": "Approval ratings climb", "fr": "La cote de popularité grimpe" },
|
||||
"d": { "en": "Citizens credit recent measures.", "fr": "Les citoyens saluent les dernières mesures." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Anger grows among the public", "fr": "La colère monte dans la population" },
|
||||
"d": { "en": "Approval slides in the latest polls.", "fr": "La cote de popularité chute dans les derniers sondages." } },
|
||||
{ "h": { "en": "Discontent in the queues", "fr": "Le mécontentement gagne les files d'attente" },
|
||||
"d": { "en": "Citizens blame the government.", "fr": "Les citoyens accusent le gouvernement." } }
|
||||
]
|
||||
},
|
||||
"economy": {
|
||||
"up": [
|
||||
{ "h": { "en": "Business confidence recovers", "fr": "La confiance des entreprises revient" },
|
||||
"d": { "en": "Factories report fuller order books.", "fr": "Les usines annoncent des carnets de commandes mieux remplis." } },
|
||||
{ "h": { "en": "Economy gathers pace", "fr": "L'économie prend de la vitesse" },
|
||||
"d": { "en": "Investors return to the capital.", "fr": "Les investisseurs reviennent dans la capitale." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Economy loses momentum", "fr": "L'économie perd de son élan" },
|
||||
"d": { "en": "Firms cut their hiring plans.", "fr": "Les entreprises réduisent leurs projets d'embauche." } },
|
||||
{ "h": { "en": "Investors turn cautious", "fr": "Les investisseurs se montrent prudents" },
|
||||
"d": { "en": "The stock exchange ends the week lower.", "fr": "La Bourse termine la semaine en baisse." } }
|
||||
]
|
||||
},
|
||||
"hardPower": {
|
||||
"up": [
|
||||
{ "h": { "en": "Armed forces strengthened", "fr": "Les forces armées se renforcent" },
|
||||
"d": { "en": "Generals welcome new readiness figures.", "fr": "Les généraux se félicitent des nouveaux chiffres de préparation." } },
|
||||
{ "h": { "en": "Military posture hardens", "fr": "La posture militaire se durcit" },
|
||||
"d": { "en": "Neighbours take note.", "fr": "Les voisins en prennent note." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Army readiness questioned", "fr": "La préparation de l'armée en question" },
|
||||
"d": { "en": "Equipment shortages reported.", "fr": "Des pénuries d'équipement sont signalées." } },
|
||||
{ "h": { "en": "Defence capacity slipping", "fr": "Les capacités de défense s'érodent" },
|
||||
"d": { "en": "Foreign analysts see a weaker military.", "fr": "Des analystes étrangers voient une armée affaiblie." } }
|
||||
]
|
||||
},
|
||||
"softPower": {
|
||||
"up": [
|
||||
{ "h": { "en": "Country's standing grows abroad", "fr": "Le prestige du pays grandit à l'étranger" },
|
||||
"d": { "en": "Foreign press praises the national image.", "fr": "La presse étrangère loue l'image du pays." } },
|
||||
{ "h": { "en": "Cultural influence expands", "fr": "L'influence culturelle s'étend" },
|
||||
"d": { "en": "Embassies report warm receptions.", "fr": "Les ambassades signalent un accueil chaleureux." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Reputation abroad takes a hit", "fr": "La réputation à l'étranger en prend un coup" },
|
||||
"d": { "en": "Foreign editorials turn critical.", "fr": "Les éditoriaux étrangers se font critiques." } },
|
||||
{ "h": { "en": "Diplomatic isolation feared", "fr": "On craint l'isolement diplomatique" },
|
||||
"d": { "en": "Partners cool towards the capital.", "fr": "Les partenaires se refroidissent envers la capitale." } }
|
||||
]
|
||||
},
|
||||
"unrest": {
|
||||
"up": [
|
||||
{ "h": { "en": "Protests spread across the country", "fr": "Les manifestations gagnent tout le pays" },
|
||||
"d": { "en": "Police on alert as crowds gather.", "fr": "La police en alerte alors que les foules se rassemblent." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Tensions ease in the streets", "fr": "Les tensions retombent dans la rue" },
|
||||
"d": { "en": "Protest movements lose steam.", "fr": "Les mouvements de protestation s'essoufflent." } }
|
||||
]
|
||||
},
|
||||
"armyLoyalty": {
|
||||
"up": [
|
||||
{ "h": { "en": "Barracks reaffirm their loyalty", "fr": "Les casernes réaffirment leur loyauté" },
|
||||
"d": { "en": "Officers pledge allegiance.", "fr": "Les officiers prêtent allégeance." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Unease reported in the barracks", "fr": "Malaise signalé dans les casernes" },
|
||||
"d": { "en": "Officers whisper about the leadership.", "fr": "Les officiers murmurent à propos de la direction." } }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"var": {
|
||||
"growth": {
|
||||
"up": [
|
||||
{ "h": { "en": "Growth picks up", "fr": "La croissance repart" },
|
||||
"d": { "en": "Statisticians revise forecasts upward.", "fr": "Les statisticiens revoient leurs prévisions à la hausse." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Growth slows", "fr": "La croissance ralentit" },
|
||||
"d": { "en": "Forecasts are revised downward.", "fr": "Les prévisions sont revues à la baisse." } }
|
||||
]
|
||||
},
|
||||
"unemployment": {
|
||||
"up": [
|
||||
{ "h": { "en": "Jobless queues lengthen", "fr": "Les files de chômeurs s'allongent" },
|
||||
"d": { "en": "Unemployment edges higher.", "fr": "Le chômage progresse." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Unemployment falls", "fr": "Le chômage recule" },
|
||||
"d": { "en": "Employment offices report more vacancies.", "fr": "Les agences pour l'emploi signalent plus d'offres." } }
|
||||
]
|
||||
},
|
||||
"inflation": {
|
||||
"up": [
|
||||
{ "h": { "en": "Prices climb faster", "fr": "Les prix s'envolent" },
|
||||
"d": { "en": "Households feel the squeeze at the market.", "fr": "Les ménages ressentent la hausse au marché." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Inflation cools", "fr": "L'inflation se calme" },
|
||||
"d": { "en": "Prices stabilise in the shops.", "fr": "Les prix se stabilisent en magasin." } }
|
||||
]
|
||||
},
|
||||
"debtRatio": {
|
||||
"up": [
|
||||
{ "h": { "en": "Public debt keeps growing", "fr": "La dette publique continue d'augmenter" },
|
||||
"d": { "en": "Bond traders watch the deficit.", "fr": "Les opérateurs obligataires surveillent le déficit." } }
|
||||
],
|
||||
"down": [
|
||||
{ "h": { "en": "Debt burden eases", "fr": "Le poids de la dette s'allège" },
|
||||
"d": { "en": "The Treasury reports progress on the deficit.", "fr": "Le Trésor annonce des progrès sur le déficit." } }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"level": {
|
||||
"unrest.high": [
|
||||
{ "h": { "en": "Capital braces for unrest", "fr": "La capitale se prépare aux troubles" },
|
||||
"d": { "en": "Police leave cancelled as crowds swell.", "fr": "Les congés de la police sont annulés alors que les foules grossissent." } }
|
||||
],
|
||||
"opinion.low": [
|
||||
{ "h": { "en": "A nation out of patience", "fr": "Une nation à bout de patience" },
|
||||
"d": { "en": "The opposition calls for the leader to go.", "fr": "L'opposition réclame le départ du dirigeant." } }
|
||||
],
|
||||
"debt.high": [
|
||||
{ "h": { "en": "Lenders demand a premium", "fr": "Les prêteurs exigent une prime" },
|
||||
"d": { "en": "The Treasury warns of a debt crisis.", "fr": "Le Trésor met en garde contre une crise de la dette." } }
|
||||
],
|
||||
"stability.low": [
|
||||
{ "h": { "en": "Government on the brink", "fr": "Le gouvernement au bord du gouffre" },
|
||||
"d": { "en": "Allies quietly count the votes.", "fr": "Les alliés comptent discrètement les voix." } }
|
||||
],
|
||||
"civilRights.low": [
|
||||
{ "h": { "en": "Dissent muzzled", "fr": "La contestation muselée" },
|
||||
"d": { "en": "Prisons fill as freedoms shrink.", "fr": "Les prisons se remplissent alors que les libertés se réduisent." } }
|
||||
],
|
||||
"inflation.high": [
|
||||
{ "h": { "en": "Prices spiral out of control", "fr": "Les prix s'envolent hors de contrôle" },
|
||||
"d": { "en": "Shoppers face another round of rises.", "fr": "Les consommateurs subissent une nouvelle vague de hausses." } }
|
||||
],
|
||||
"deficit.high": [
|
||||
{ "h": { "en": "Treasury bleeds red ink", "fr": "Le Trésor saigne" },
|
||||
"d": { "en": "The budget gap widens to alarming levels.", "fr": "Le déficit budgétaire atteint des niveaux alarmants." } }
|
||||
],
|
||||
"army.low": [
|
||||
{ "h": { "en": "The army grows restless", "fr": "L'armée s'impatiente" },
|
||||
"d": { "en": "The generals' loyalty is in doubt.", "fr": "La loyauté des généraux est mise en doute." } }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"id": "default",
|
||||
"name": { "en": "Republic of Valdoria", "fr": "République de Valdorie" },
|
||||
"startDate": "2027-01-04",
|
||||
"currency": "VLD",
|
||||
|
||||
"economy": {
|
||||
"gdp": 480,
|
||||
"debtRatio": 58,
|
||||
"growth": 2.0,
|
||||
"unemployment": 6.5,
|
||||
"inflation": 2.5,
|
||||
"otherRevenue": 11.5,
|
||||
"taxes": { "income": 30, "corporate": 25, "vat": 20 }
|
||||
},
|
||||
|
||||
"indicators": {
|
||||
"order": 62,
|
||||
"stability": 58,
|
||||
"civilRights": 64,
|
||||
"opinion": 52,
|
||||
"economy": 55,
|
||||
"hardPower": 45,
|
||||
"softPower": 48,
|
||||
"unrest": 24,
|
||||
"armyLoyalty": 65
|
||||
},
|
||||
|
||||
"sectors": {
|
||||
"education": { "need": 4.8, "alloc": 4.5 },
|
||||
"health": { "need": 6.5, "alloc": 6.0 },
|
||||
"social": { "need": 10.0, "alloc": 10.0 },
|
||||
"police": { "need": 1.2, "alloc": 1.2 },
|
||||
"army": { "need": 2.0, "alloc": 2.0 },
|
||||
"justice": { "need": 0.8, "alloc": 0.8 },
|
||||
"industry": { "need": 1.5, "alloc": 1.5 },
|
||||
"transport": { "need": 1.2, "alloc": 1.2 },
|
||||
"infrastructure": { "need": 1.8, "alloc": 1.5 },
|
||||
"housing": { "need": 1.2, "alloc": 1.0 },
|
||||
"research": { "need": 1.0, "alloc": 0.8 },
|
||||
"culture": { "need": 0.5, "alloc": 0.5 },
|
||||
"foreignAffairs": { "need": 0.4, "alloc": 0.4 },
|
||||
"environment": { "need": 0.9, "alloc": 0.7 },
|
||||
"administration": { "need": 2.2, "alloc": 2.2 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"scenarios": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Headless balance runner. No DOM, no fetch: works in the browser (sim.html) and in Node.
|
||||
|
||||
import {
|
||||
createGame, advanceDay, summarize, setBudget, confirmBudget, ensureCard, answerCard, nextFloat, dismissAlert,
|
||||
} from "../js/engine/index.js";
|
||||
|
||||
// A strategy decides what to do at each quarterly review (review) and how to answer
|
||||
// each weekly card (answer, default: a coin flip from the game's own RNG).
|
||||
export const STRATEGIES = {
|
||||
baseline: { label: "Baseline (change nothing)", review: () => ({}) },
|
||||
yesMan: { label: "Yes to every card", review: () => ({}), answer: () => "yes" },
|
||||
noMan: { label: "No to every card", review: () => ({}), answer: () => "no" },
|
||||
spendthrift: {
|
||||
label: "Spendthrift (all budgets x1.4)",
|
||||
review: (state) => ({ alloc: scaled(state, 1.4) }),
|
||||
},
|
||||
austerity: {
|
||||
label: "Austerity (all budgets x0.75)",
|
||||
review: (state) => ({ alloc: scaled(state, 0.75) }),
|
||||
},
|
||||
policeState: {
|
||||
label: "Police state (police x3, army x1.5)",
|
||||
review: (state) => ({ alloc: { police: state.sectors.police.need * 3, army: state.sectors.army.need * 1.5 } }),
|
||||
},
|
||||
taxTheRich: {
|
||||
label: "Tax hike (income +10, corporate +10)",
|
||||
review: (state) => ({ taxes: { income: state.ref.taxes.income + 10, corporate: state.ref.taxes.corporate + 10 } }),
|
||||
},
|
||||
// Deliberately bad play: each should be able to end the term early.
|
||||
ruin: {
|
||||
label: "Ruin (all budgets x2, taxes cut to 15/12/10)",
|
||||
review: (state) => ({ alloc: scaled(state, 2), taxes: { income: 15, corporate: 12, vat: 10 } }),
|
||||
},
|
||||
starve: {
|
||||
label: "Starve (all budgets x0.25, taxes up)",
|
||||
review: (state) => ({
|
||||
alloc: scaled(state, 0.25),
|
||||
taxes: { income: state.ref.taxes.income + 15, corporate: state.ref.taxes.corporate + 10, vat: state.ref.taxes.vat + 10 },
|
||||
}),
|
||||
},
|
||||
tyrant: {
|
||||
label: "Tyrant (police x3, army x2, justice and social x0.3)",
|
||||
review: (state) => ({
|
||||
alloc: {
|
||||
...scaled(state, 0.7),
|
||||
police: state.sectors.police.need * 3,
|
||||
army: state.sectors.army.need * 2,
|
||||
justice: state.sectors.justice.need * 0.3,
|
||||
social: state.sectors.social.need * 0.3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
neglectArmy: {
|
||||
label: "Neglect the army (army x0.1, the rest x1.1)",
|
||||
review: (state) => ({ alloc: { ...scaled(state, 1.1), army: state.sectors.army.need * 0.1 } }),
|
||||
},
|
||||
};
|
||||
|
||||
function scaled(state, k) {
|
||||
const out = {};
|
||||
for (const [id, s] of Object.entries(state.sectors)) out[id] = s.need * (state.ref.ratio[id] * k);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Runs one game to the end of `years` (or the end of the term) and samples every `sampleEvery` days.
|
||||
export function runSim({ scenario, balance, cards = [], strategy, years = 1, seed = 1, sampleEvery = 30 }) {
|
||||
const state = createGame({ scenario, balance, seed, pronouns: "they" });
|
||||
const maxDay = Math.min(balance.time.termDays, Math.round(years * 365));
|
||||
const rows = [];
|
||||
const strat = STRATEGIES[strategy];
|
||||
|
||||
const sample = () => rows.push(summarize(state, balance));
|
||||
sample();
|
||||
|
||||
while (!state.over && state.day < maxDay) {
|
||||
if (state.pending.review) {
|
||||
const res = setBudget(state, balance, strat.review(state));
|
||||
if (!res.ok) throw new Error(`strategy ${strategy} produced an invalid budget: ${res.errors.join(", ")}`);
|
||||
confirmBudget(state);
|
||||
}
|
||||
while (state.pending.alert) dismissAlert(state);
|
||||
if (state.pending.weeklyCard) {
|
||||
const card = ensureCard(state, balance, cards);
|
||||
if (card) {
|
||||
const choice = strat.answer ? strat.answer(state, card) : nextFloat(state) < 0.5 ? "yes" : "no";
|
||||
answerCard(state, balance, cards, choice);
|
||||
}
|
||||
}
|
||||
advanceDay(state, balance);
|
||||
if (state.day % sampleEvery === 0) sample();
|
||||
}
|
||||
sample();
|
||||
return { rows, final: rows[rows.length - 1], over: state.over };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Balance sim</title>
|
||||
<style>
|
||||
body { font: 13px/1.4 "Courier New", monospace; margin: 24px; background: #1d1a15; color: #e8dfc8; }
|
||||
h1, h2 { font-weight: normal; }
|
||||
label { margin-right: 16px; }
|
||||
input, select, button { font: inherit; background: #2b261e; color: inherit; border: 1px solid #6b5a3e; padding: 3px 6px; }
|
||||
button { cursor: pointer; }
|
||||
table { border-collapse: collapse; margin: 12px 0 28px; }
|
||||
th, td { border: 1px solid #4a4030; padding: 2px 8px; text-align: right; }
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
th { background: #2b261e; }
|
||||
.bad { color: #ff8a6b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Balance sim</h1>
|
||||
<p>
|
||||
<label>Years <input id="years" type="number" min="1" max="5" value="1" style="width:4em"></label>
|
||||
<label>Seed <input id="seed" type="number" value="1" style="width:6em"></label>
|
||||
<label>Detail <select id="detail"></select></label>
|
||||
<button id="run">Run</button>
|
||||
</p>
|
||||
<h2>Final state per strategy</h2>
|
||||
<div id="summary"></div>
|
||||
<h2>Timeline</h2>
|
||||
<div id="timeline"></div>
|
||||
<script type="module">
|
||||
import { runSim, STRATEGIES } from "./sim-core.js";
|
||||
|
||||
const getJson = (u) => fetch(u).then((r) => r.json());
|
||||
const [balance, scenario, cardIndex] = await Promise.all([
|
||||
getJson("../data/balance.json"),
|
||||
getJson("../data/scenarios/default.json"),
|
||||
getJson("../data/cards/index.json"),
|
||||
]);
|
||||
const cards = (await Promise.all(cardIndex.files.map((f) => getJson(`../data/cards/${f}`)))).flat();
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const detail = $("detail");
|
||||
for (const [k, s] of Object.entries(STRATEGIES)) detail.add(new Option(s.label, k));
|
||||
|
||||
const COLS = [
|
||||
["day", "day", 0], ["growth", "growth %", 2], ["unemployment", "unempl %", 1], ["inflation", "infl %", 1],
|
||||
["debtRatio", "debt %GDP", 1], ["deficit", "deficit %GDP", 2], ["interestRate", "rate %", 2],
|
||||
["order", "order", 0], ["stability", "stab", 0], ["civilRights", "civil", 0], ["opinion", "opinion", 0],
|
||||
["economy", "econ", 0], ["hardPower", "hard", 0], ["softPower", "soft", 0], ["unrest", "unrest", 0], ["armyLoyalty", "army", 0],
|
||||
];
|
||||
const val = (row, k) => (k in row ? row[k] : row.indicators[k]);
|
||||
|
||||
function table(rows, firstCol) {
|
||||
const head = `<tr><th>${firstCol}</th>${COLS.map((c) => `<th>${c[1]}</th>`).join("")}</tr>`;
|
||||
const body = rows.map(([name, row]) =>
|
||||
`<tr><td>${name}</td>${COLS.map((c) => `<td>${val(row, c[0]).toFixed(c[2])}</td>`).join("")}</tr>`).join("");
|
||||
return `<table>${head}${body}</table>`;
|
||||
}
|
||||
|
||||
function run() {
|
||||
const years = Number($("years").value);
|
||||
const seed = Number($("seed").value);
|
||||
const results = {};
|
||||
for (const k of Object.keys(STRATEGIES)) results[k] = runSim({ scenario, balance, cards, strategy: k, years, seed });
|
||||
const ended = (r) => (r.over && r.over.type !== "term" ? ` ← ${r.over.type}, day ${r.over.day}` : "");
|
||||
$("summary").innerHTML = table(Object.entries(results).map(([k, r]) => [STRATEGIES[k].label + ended(r), r.final]), "strategy");
|
||||
const d = results[detail.value];
|
||||
$("timeline").innerHTML = table(d.rows.map((row) => [`d${row.day}`, row]), "day");
|
||||
}
|
||||
|
||||
$("run").addEventListener("click", run);
|
||||
detail.addEventListener("change", run);
|
||||
run();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
// Package web embeds the static frontend so the server ships as one binary.
|
||||
// It lives next to the files because go:embed cannot reference parent directories.
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed index.html css js data dev fonts img
|
||||
var Files embed.FS
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 The Courier Prime Project Authors (https://github.com/quoteunquoteapps/CourierPrime).
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
Placeholder so go:embed accepts this folder. Delete once real files exist.
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>The Desk</title>
|
||||
<link rel="stylesheet" href="css/desk.css">
|
||||
<link rel="stylesheet" href="css/papers.css">
|
||||
<link rel="stylesheet" href="css/ui.css">
|
||||
<link rel="stylesheet" href="css/ledger.css">
|
||||
<link rel="stylesheet" href="css/decision.css">
|
||||
<link rel="stylesheet" href="css/endgame.css">
|
||||
<link rel="stylesheet" href="css/menu.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="desk">
|
||||
<div id="pad">
|
||||
<section id="dashboard" class="paper paper--graph" aria-label="Ministerial briefing"></section>
|
||||
<section id="folder" class="paper paper--folder"></section>
|
||||
<section id="ledger" class="ledger" hidden aria-label="Treasury ledger"></section>
|
||||
<section id="endgame" hidden aria-label="End of term"></section>
|
||||
<section id="alerts" class="alerts" hidden></section>
|
||||
<section id="menu" hidden aria-label="Menu"></section>
|
||||
<div id="side">
|
||||
<section id="calendar" class="paper paper--calendar"></section>
|
||||
<section id="newspaper" class="paper paper--news"></section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
// Weekly decision cards: drawing, eligibility and applying the player's answer.
|
||||
//
|
||||
// Card: { id, category, doc, sender, weight?, once?, cooldownDays?, followUpOnly?,
|
||||
// conditions?: { min:{stat:n}, max:{stat:n}, flags:[], notFlags:[], dayMin, dayMax },
|
||||
// title, body, yes: Option, no: Option }
|
||||
// Option: { label, effects: [...see modifiers.js], setFlags?, clearFlags?, followUp?: { id, inDays } }
|
||||
// Condition stats: any indicator, growth/unemployment/inflation, "debt" (% of GDP),
|
||||
// "deficit" (% of GDP) and "q:<sector>" (sector quality).
|
||||
|
||||
import { IND_IDS, VAR_IDS, clamp } from "./state.js";
|
||||
import { computeFiscal } from "./economy.js";
|
||||
import { applyEffect } from "./modifiers.js";
|
||||
import { pickWeighted } from "./rng.js";
|
||||
|
||||
export const indexCards = (cards) => new Map(cards.map((c) => [c.id, c]));
|
||||
|
||||
function statValue(state, key, fiscal) {
|
||||
if (IND_IDS.includes(key)) return state.indicators[key];
|
||||
if (VAR_IDS.includes(key)) return state.economy[key];
|
||||
if (key === "debt") return fiscal.debtRatio;
|
||||
if (key === "deficit") return fiscal.deficit;
|
||||
if (key.startsWith("q:")) return state.sectors[key.slice(2)]?.quality ?? NaN;
|
||||
return NaN; // unknown key: never eligible (content tests catch typos)
|
||||
}
|
||||
|
||||
function meetsConditions(card, state, fiscal) {
|
||||
const c = card.conditions;
|
||||
if (!c) return true;
|
||||
if (c.dayMin !== undefined && state.day < c.dayMin) return false;
|
||||
if (c.dayMax !== undefined && state.day > c.dayMax) return false;
|
||||
for (const [k, v] of Object.entries(c.min ?? {})) if (!(statValue(state, k, fiscal) >= v)) return false;
|
||||
for (const [k, v] of Object.entries(c.max ?? {})) if (!(statValue(state, k, fiscal) <= v)) return false;
|
||||
for (const f of c.flags ?? []) if (!state.flags[f]) return false;
|
||||
for (const f of c.notFlags ?? []) if (state.flags[f]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// A sector (or indicator) in trouble makes cards about it more likely.
|
||||
function boostFor(card, state, bal) {
|
||||
const b = bal.cards.boost[card.category];
|
||||
if (!b) return 1;
|
||||
const v = b.sector ? state.sectors[b.sector].quality : state.indicators[b.indicator];
|
||||
const below = bal.cards.boostBelow;
|
||||
return 1 + bal.cards.boostMax * clamp((below - v) / below, 0, 1);
|
||||
}
|
||||
|
||||
export function drawCard(state, bal, cards) {
|
||||
const fiscal = computeFiscal(state, bal);
|
||||
const lastAnswered = new Map();
|
||||
for (const h of state.cardHistory) lastAnswered.set(h.id, h.day);
|
||||
const recent = state.cardHistory.slice(-bal.cards.recentWindow).map((h) => h.category);
|
||||
|
||||
const pool = [];
|
||||
const weights = [];
|
||||
for (const card of cards) {
|
||||
if (card.followUpOnly) continue;
|
||||
const last = lastAnswered.get(card.id);
|
||||
if (last !== undefined) {
|
||||
if (card.once !== false) continue;
|
||||
if (state.day - last < (card.cooldownDays ?? bal.cards.defaultCooldown)) continue;
|
||||
}
|
||||
if (!meetsConditions(card, state, fiscal)) continue;
|
||||
const seen = recent.filter((c) => c === card.category).length;
|
||||
pool.push(card);
|
||||
weights.push((card.weight ?? 1) * boostFor(card, state, bal) * Math.pow(bal.cards.recentPenalty, seen));
|
||||
}
|
||||
return pickWeighted(state, pool, weights);
|
||||
}
|
||||
|
||||
// Called once a weekly pause is pending: puts the card to answer in state.pendingCard.
|
||||
// A due follow-up always comes first. With no eligible card the week is simply empty.
|
||||
export function ensureCard(state, bal, cards) {
|
||||
if (!state.pending.weeklyCard || state.pendingCard) return null;
|
||||
const byId = indexCards(cards);
|
||||
|
||||
let card = null;
|
||||
const due = state.followUps.findIndex((f) => f.due <= state.day);
|
||||
if (due >= 0) card = byId.get(state.followUps.splice(due, 1)[0].id) ?? null;
|
||||
card ??= drawCard(state, bal, cards);
|
||||
|
||||
if (!card) {
|
||||
state.pending.weeklyCard = false;
|
||||
return null;
|
||||
}
|
||||
state.pendingCard = { id: card.id, day: state.day };
|
||||
return card;
|
||||
}
|
||||
|
||||
export function answerCard(state, bal, cards, choice) {
|
||||
if (!state.pendingCard || (choice !== "yes" && choice !== "no")) return { ok: false };
|
||||
const card = indexCards(cards).get(state.pendingCard.id);
|
||||
if (!card) return { ok: false };
|
||||
|
||||
const option = card[choice];
|
||||
for (const effect of option.effects ?? []) applyEffect(state, bal, effect);
|
||||
for (const f of option.setFlags ?? []) state.flags[f] = state.day;
|
||||
for (const f of option.clearFlags ?? []) delete state.flags[f];
|
||||
if (option.followUp) state.followUps.push({ id: option.followUp.id, due: state.day + option.followUp.inDays });
|
||||
|
||||
state.cardHistory.push({ id: card.id, category: card.category, day: state.day, choice });
|
||||
state.pendingCard = null;
|
||||
state.pending.weeklyCard = false;
|
||||
return { ok: true, card };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Public finances: revenue, spending, interest, debt and GDP. All flows are in
|
||||
// % of GDP per year; debt and gdp are absolute (billions of the scenario currency).
|
||||
|
||||
import { TAX_IDS, clamp } from "./state.js";
|
||||
|
||||
// Laffer-style curve: revenue peaks at rate == peak, then falls.
|
||||
export function taxRevenue(id, rate, bal) {
|
||||
const t = bal.tax[id];
|
||||
return t.coef * rate * (1 - rate / (2 * t.peak));
|
||||
}
|
||||
|
||||
export function interestRate(debtRatio, bal) {
|
||||
const d = bal.debt;
|
||||
const excess = Math.max(0, debtRatio - d.premiumFrom);
|
||||
return d.baseRate + d.premiumCoef * excess * excess;
|
||||
}
|
||||
|
||||
export function computeFiscal(state, bal) {
|
||||
const e = state.economy;
|
||||
let revenue = e.otherRevenue;
|
||||
for (const id of TAX_IDS) revenue += taxRevenue(id, e.taxes[id], bal);
|
||||
|
||||
const debtRatio = (e.debt / e.gdp) * 100;
|
||||
const rate = interestRate(debtRatio, bal);
|
||||
const interest = (debtRatio * rate) / 100;
|
||||
|
||||
let allocTotal = 0;
|
||||
for (const s of Object.values(state.sectors)) allocTotal += s.alloc;
|
||||
const spending = allocTotal + interest + e.extraSpending;
|
||||
|
||||
return { revenue, allocTotal, interest, spending, deficit: spending - revenue, debtRatio, rate };
|
||||
}
|
||||
|
||||
// Derived quantities that indicator/variable targets are written against.
|
||||
// ref values are captured at game creation so the start is an equilibrium.
|
||||
export function computeDevs(state, bal, fiscal) {
|
||||
const e = state.economy;
|
||||
let taxPressure = 0;
|
||||
let taxGrowthDrag = 0;
|
||||
for (const id of TAX_IDS) {
|
||||
const d = e.taxes[id] - state.ref.taxes[id];
|
||||
taxPressure += bal.tax[id].opinion * d;
|
||||
taxGrowthDrag += bal.tax[id].growth * d;
|
||||
}
|
||||
const start = bal.crackdown.unrestStart;
|
||||
return {
|
||||
growth: e.growth,
|
||||
unemployment: e.unemployment,
|
||||
inflation: e.inflation,
|
||||
debt: Math.max(0, fiscal.debtRatio - bal.debt.freeBelow),
|
||||
deficit: fiscal.deficit,
|
||||
taxPressure,
|
||||
taxGrowthDrag,
|
||||
// police sent in when unrest is high: 0..1
|
||||
crackdown: clamp((state.indicators.unrest - start) / (100 - start), 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceEconomy(state, fiscal) {
|
||||
const e = state.economy;
|
||||
e.debt = Math.max(0, e.debt + ((fiscal.deficit / 100) * e.gdp) / 365);
|
||||
e.gdp *= Math.pow(1 + e.growth / 100, 1 / 365);
|
||||
}
|
||||
|
||||
// Player action, only allowed while the quarterly review is open.
|
||||
export function setBudget(state, bal, { alloc = {}, taxes = {} } = {}) {
|
||||
if (!state.pending.review) return { ok: false, errors: ["budget_locked"] };
|
||||
|
||||
const errors = [];
|
||||
for (const [id, v] of Object.entries(alloc)) {
|
||||
const s = state.sectors[id];
|
||||
if (!s) errors.push(`unknown_sector:${id}`);
|
||||
else if (!(v >= 0 && v <= s.need * bal.limits.allocMaxRatio)) errors.push(`alloc_out_of_range:${id}`);
|
||||
}
|
||||
for (const [id, v] of Object.entries(taxes)) {
|
||||
if (!TAX_IDS.includes(id)) errors.push(`unknown_tax:${id}`);
|
||||
else if (!(v >= 0 && v <= bal.tax[id].peak * bal.limits.taxMaxFactorOfPeak)) errors.push(`tax_out_of_range:${id}`);
|
||||
}
|
||||
if (errors.length) return { ok: false, errors };
|
||||
|
||||
for (const [id, v] of Object.entries(alloc)) state.sectors[id].alloc = v;
|
||||
for (const [id, v] of Object.entries(taxes)) state.economy.taxes[id] = v;
|
||||
return { ok: true, errors: [] };
|
||||
}
|
||||
|
||||
export function confirmBudget(state) {
|
||||
state.pending.review = false;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Game creation and the daily tick. Pure: data (balance, scenario) is passed in.
|
||||
|
||||
import { STATE_VERSION, IND_IDS, VAR_IDS, qualityFromRatio } from "./state.js";
|
||||
import { computeFiscal, computeDevs, advanceEconomy } from "./economy.js";
|
||||
import { applyShifts, ageBias } from "./modifiers.js";
|
||||
import { stepSectors } from "./sectors.js";
|
||||
import { stepIndicators, stepVars } from "./indicators.js";
|
||||
import { checkGameOver } from "./gameover.js";
|
||||
|
||||
export const PRONOUNS = ["he", "she", "they"];
|
||||
|
||||
// pronouns: omit it and the game opens with the "who are you" question (pending.profile).
|
||||
export function createGame({ scenario, balance, seed = Date.now(), countryName, lang = "en", pronouns }) {
|
||||
const se = scenario.economy;
|
||||
const gdp = se.gdp;
|
||||
|
||||
const sectors = {};
|
||||
for (const [id, s] of Object.entries(scenario.sectors)) {
|
||||
sectors[id] = { need: s.need, alloc: s.alloc, quality: qualityFromRatio(s.alloc / s.need, balance) };
|
||||
}
|
||||
|
||||
const state = {
|
||||
version: STATE_VERSION,
|
||||
seed: seed >>> 0,
|
||||
rngState: seed >>> 0,
|
||||
day: 0,
|
||||
speed: 1, // 0 = paused, otherwise one of balance.time.speeds
|
||||
startDate: scenario.startDate,
|
||||
lang,
|
||||
pronouns: PRONOUNS.includes(pronouns) ? pronouns : "they",
|
||||
countryName: countryName ?? scenario.name[lang] ?? scenario.name.en,
|
||||
scenarioId: scenario.id,
|
||||
economy: {
|
||||
gdp,
|
||||
debt: (gdp * se.debtRatio) / 100,
|
||||
growth: se.growth,
|
||||
unemployment: se.unemployment,
|
||||
inflation: se.inflation,
|
||||
taxes: { ...se.taxes },
|
||||
otherRevenue: se.otherRevenue,
|
||||
extraSpending: 0,
|
||||
},
|
||||
sectors,
|
||||
indicators: { ...scenario.indicators },
|
||||
modifiers: [],
|
||||
flags: {},
|
||||
cardHistory: [],
|
||||
pendingCard: null,
|
||||
followUps: [],
|
||||
// Day 0 opens with the pronoun question, the initial budget review and a first card.
|
||||
// Any true flag here pauses time (see advanceDay).
|
||||
pending: { profile: !PRONOUNS.includes(pronouns), weeklyCard: true, review: true, alert: false },
|
||||
over: null,
|
||||
risk: { revolution: 0, coup: 0, impeachment: 0, defaultZone: false }, // days in each danger zone
|
||||
alerts: [], // urgent telegrams waiting to be read, oldest first
|
||||
history: { t: [], series: {} },
|
||||
log: [],
|
||||
ref: null,
|
||||
};
|
||||
|
||||
state.ref = {
|
||||
taxes: { ...se.taxes },
|
||||
ind: { ...scenario.indicators },
|
||||
vars: { growth: se.growth, unemployment: se.unemployment, inflation: se.inflation },
|
||||
sectors: Object.fromEntries(Object.entries(sectors).map(([id, s]) => [id, s.quality])),
|
||||
ratio: Object.fromEntries(Object.entries(sectors).map(([id, s]) => [id, s.alloc / s.need])),
|
||||
dev: null,
|
||||
};
|
||||
state.ref.dev = computeDevs(state, balance, computeFiscal(state, balance));
|
||||
|
||||
recordHistory(state, balance);
|
||||
return state;
|
||||
}
|
||||
|
||||
function recordHistory(state, bal) {
|
||||
const f = computeFiscal(state, bal);
|
||||
const h = state.history;
|
||||
h.t.push(state.day);
|
||||
const sample = { debtRatio: f.debtRatio, deficit: f.deficit };
|
||||
for (const id of IND_IDS) sample[id] = state.indicators[id];
|
||||
for (const id of VAR_IDS) sample[id] = state.economy[id];
|
||||
for (const [k, v] of Object.entries(sample)) (h.series[k] ??= []).push(round(v));
|
||||
}
|
||||
|
||||
const round = (v) => Math.round(v * 100) / 100;
|
||||
|
||||
export function setSpeed(state, bal, speed) {
|
||||
if (speed !== 0 && !bal.time.speeds.includes(speed)) return false;
|
||||
state.speed = speed;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Clears a pause condition once the player has dealt with it.
|
||||
export function clearPending(state, kind) {
|
||||
state.pending[kind] = false;
|
||||
}
|
||||
|
||||
export function setPronouns(state, pronouns) {
|
||||
if (!PRONOUNS.includes(pronouns)) return false;
|
||||
state.pronouns = pronouns;
|
||||
state.pending.profile = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// True while the game is waiting on the player (profile, budget review, weekly card, alert).
|
||||
export const isWaiting = (state) => Object.values(state.pending).some(Boolean);
|
||||
|
||||
// Advances one in-game day unless the game is over or waiting on the player.
|
||||
export function advanceDay(state, bal) {
|
||||
if (state.over) return { advanced: false, blocked: "over", events: null };
|
||||
if (isWaiting(state)) return { advanced: false, blocked: "pending", events: null };
|
||||
|
||||
applyShifts(state, bal);
|
||||
const fiscal = computeFiscal(state, bal);
|
||||
const devs = computeDevs(state, bal, fiscal);
|
||||
|
||||
advanceEconomy(state, fiscal);
|
||||
stepVars(state, bal, devs);
|
||||
stepIndicators(state, bal, devs);
|
||||
stepSectors(state, bal, fiscal);
|
||||
ageBias(state);
|
||||
|
||||
state.day += 1;
|
||||
|
||||
const events = { weeklyCard: false, review: false, termEnd: false, gameOver: null };
|
||||
const fallen = checkGameOver(state, bal);
|
||||
if (fallen) {
|
||||
state.over = { type: fallen, day: state.day };
|
||||
events.gameOver = fallen;
|
||||
recordHistory(state, bal);
|
||||
} else if (state.day >= bal.time.termDays) {
|
||||
state.over = { type: "term", day: state.day };
|
||||
events.termEnd = true;
|
||||
recordHistory(state, bal);
|
||||
} else {
|
||||
if (state.day % bal.time.historyEvery === 0) recordHistory(state, bal);
|
||||
if (state.day % bal.time.daysPerWeek === 0) state.pending.weeklyCard = events.weeklyCard = true;
|
||||
if (state.day % bal.time.daysPerQuarter === 0) state.pending.review = events.review = true;
|
||||
}
|
||||
return { advanced: true, blocked: null, events };
|
||||
}
|
||||
|
||||
// Read-only snapshot for the UI and the sim.
|
||||
export function summarize(state, bal) {
|
||||
const f = computeFiscal(state, bal);
|
||||
return {
|
||||
day: state.day,
|
||||
gdp: state.economy.gdp,
|
||||
debt: state.economy.debt,
|
||||
debtRatio: f.debtRatio,
|
||||
interestRate: f.rate,
|
||||
revenue: f.revenue,
|
||||
spending: f.spending,
|
||||
interest: f.interest,
|
||||
deficit: f.deficit,
|
||||
growth: state.economy.growth,
|
||||
unemployment: state.economy.unemployment,
|
||||
inflation: state.economy.inflation,
|
||||
indicators: { ...state.indicators },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Ways the term can end early. Checked once per day after the economy has moved.
|
||||
//
|
||||
// default debt above the limit: immediate
|
||||
// impeachment civil rights and opinion both very low for N days in a row: certain
|
||||
// coup army loyalty and stability both very low: a daily chance that grows each day in the zone
|
||||
// revolution unrest very high and opinion very low: same growing daily chance
|
||||
//
|
||||
// Entering a danger zone queues an urgent telegram (state.alerts) and pauses the game
|
||||
// until the player acknowledges it. state.risk counts the days spent in each zone.
|
||||
|
||||
import { nextFloat } from "./rng.js";
|
||||
|
||||
function alert(state, kind) {
|
||||
state.alerts.push({ kind, day: state.day });
|
||||
state.pending.alert = true;
|
||||
}
|
||||
|
||||
// Returns the game-over type or null. Mutates state.risk, state.alerts and the RNG.
|
||||
export function checkGameOver(state, bal) {
|
||||
const g = bal.gameOver;
|
||||
const ind = state.indicators;
|
||||
const risk = state.risk;
|
||||
const debtRatio = (state.economy.debt / state.economy.gdp) * 100;
|
||||
|
||||
// Zone bookkeeping: count consecutive days inside, fade (or reset) outside.
|
||||
const track = (name, inZone, fade) => {
|
||||
if (inZone) {
|
||||
risk[name] += 1;
|
||||
if (risk[name] === 1) alert(state, name);
|
||||
} else {
|
||||
risk[name] = fade === "reset" ? 0 : Math.max(0, risk[name] - fade);
|
||||
}
|
||||
};
|
||||
const chance = (name, perDay) => risk[name] > 0 && nextFloat(state) < Math.min(1, perDay * risk[name]);
|
||||
|
||||
if (debtRatio >= g.default.warnAbove) {
|
||||
if (!risk.defaultZone) {
|
||||
risk.defaultZone = true;
|
||||
alert(state, "default");
|
||||
}
|
||||
} else if (debtRatio < g.default.warnAbove - 10) {
|
||||
risk.defaultZone = false;
|
||||
}
|
||||
|
||||
const inImpeachment = ind.civilRights < g.impeachment.civilRightsBelow && ind.opinion < g.impeachment.opinionBelow;
|
||||
const inCoup = ind.armyLoyalty < g.coup.armyBelow && ind.stability < g.coup.stabilityBelow;
|
||||
const inRevolution = ind.unrest > g.revolution.unrestAbove && ind.opinion < g.revolution.opinionBelow;
|
||||
track("impeachment", inImpeachment, "reset");
|
||||
track("coup", inCoup, 1);
|
||||
track("revolution", inRevolution, 1);
|
||||
|
||||
if (debtRatio > g.default.debtAbove) return "default";
|
||||
if (risk.impeachment >= g.impeachment.days) return "impeachment";
|
||||
if (inCoup && chance("coup", g.coup.chancePerDay)) return "coup";
|
||||
if (inRevolution && chance("revolution", g.revolution.chancePerDay)) return "revolution";
|
||||
return null;
|
||||
}
|
||||
|
||||
// The player has read the telegram at the front of the queue.
|
||||
export function dismissAlert(state) {
|
||||
state.alerts.shift();
|
||||
state.pending.alert = state.alerts.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Public engine API. The UI and tools import from here only.
|
||||
|
||||
export { createGame, advanceDay, clearPending, setSpeed, setPronouns, isWaiting, summarize, PRONOUNS } from "./game.js";
|
||||
export { dismissAlert } from "./gameover.js";
|
||||
export { buildReview, optionImpact, METRIC_IDS } from "./score.js";
|
||||
export { restoreState } from "./validate.js";
|
||||
export { setBudget, confirmBudget, computeFiscal, taxRevenue } from "./economy.js";
|
||||
export { previewBudget } from "./projection.js";
|
||||
export { qualityFromRatio } from "./state.js";
|
||||
export { applyEffect } from "./modifiers.js";
|
||||
export { ensureCard, answerCard, drawCard, indexCards } from "./cards.js";
|
||||
export { createClock, consumeClock, dateOfDay, weekOfDay, quarterOfDay } from "./time.js";
|
||||
export { nextFloat, nextInt, pickWeighted } from "./rng.js";
|
||||
export { IND_IDS, VAR_IDS, TAX_IDS } from "./state.js";
|
||||
@@ -0,0 +1,47 @@
|
||||
// Indicators and economy variables move toward a target each day:
|
||||
// target = start value + sum of weighted deviations from the start state + active biases
|
||||
// Because every term is a deviation from the scenario start, the start is an equilibrium.
|
||||
|
||||
import { IND_IDS, VAR_IDS, clamp } from "./state.js";
|
||||
import { biasFor } from "./modifiers.js";
|
||||
import { nextNoise } from "./rng.js";
|
||||
|
||||
function computeTarget(def, base, group, key, state, devs) {
|
||||
let t = base;
|
||||
for (const [id, w] of Object.entries(def.sectors ?? {})) {
|
||||
t += w * (state.sectors[id].quality - state.ref.sectors[id]);
|
||||
}
|
||||
for (const [id, w] of Object.entries(def.alloc ?? {})) {
|
||||
const s = state.sectors[id];
|
||||
t += w * (s.alloc / s.need - state.ref.ratio[id]);
|
||||
}
|
||||
for (const [k, w] of Object.entries(def.econ ?? {})) {
|
||||
t += w * (devs[k] - state.ref.dev[k]);
|
||||
}
|
||||
for (const [k, w] of Object.entries(def.ind ?? {})) {
|
||||
t += w * (state.indicators[k] - state.ref.ind[k]);
|
||||
}
|
||||
return t + biasFor(state, group, key);
|
||||
}
|
||||
|
||||
export function stepVars(state, bal, devs) {
|
||||
const next = {};
|
||||
for (const id of VAR_IDS) {
|
||||
const def = bal.vars[id];
|
||||
const target = computeTarget(def, state.ref.vars[id], "var", id, state, devs);
|
||||
const v = state.economy[id];
|
||||
next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise), def.min, def.max);
|
||||
}
|
||||
Object.assign(state.economy, next);
|
||||
}
|
||||
|
||||
export function stepIndicators(state, bal, devs) {
|
||||
const next = {};
|
||||
for (const id of IND_IDS) {
|
||||
const def = bal.indicators[id];
|
||||
const target = computeTarget(def, state.ref.ind[id], "ind", id, state, devs);
|
||||
const v = state.indicators[id];
|
||||
next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise ?? bal.noise.indicator), 0, 100);
|
||||
}
|
||||
Object.assign(state.indicators, next);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Effects and timed modifiers. Card choices end up here.
|
||||
//
|
||||
// Effect shapes:
|
||||
// { stat, delta } instant shift of an indicator or economy variable
|
||||
// { stat, delta, days } shift spread evenly over `days` (then the value mean-reverts)
|
||||
// { stat, delta, bias: true, days? } offset on the stat's *target* for `days` (permanent if omitted)
|
||||
// { sector, delta[, days][, bias] } same, on a sector's quality
|
||||
// { revenue: "income"|"corporate"|"vat", delta } change tax rate (rate points), permanent
|
||||
// { revenue: "other", delta } change other revenue (% of GDP), permanent
|
||||
// { oneOff: pct } one-time cost in % of GDP (negative = windfall)
|
||||
// { extraSpending: delta } permanent extra spending in % of GDP
|
||||
|
||||
import { IND_IDS, VAR_IDS, TAX_IDS, clamp } from "./state.js";
|
||||
|
||||
function groupOf(effect, state) {
|
||||
if (effect.sector !== undefined) return state.sectors[effect.sector] ? ["sector", effect.sector] : null;
|
||||
if (IND_IDS.includes(effect.stat)) return ["ind", effect.stat];
|
||||
if (VAR_IDS.includes(effect.stat)) return ["var", effect.stat];
|
||||
return null;
|
||||
}
|
||||
|
||||
function shift(state, bal, group, key, delta) {
|
||||
if (group === "ind") state.indicators[key] = clamp(state.indicators[key] + delta, 0, 100);
|
||||
else if (group === "sector") state.sectors[key].quality = clamp(state.sectors[key].quality + delta, 0, 100);
|
||||
else {
|
||||
const v = bal.vars[key];
|
||||
state.economy[key] = clamp(state.economy[key] + delta, v.min, v.max);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEffect(state, bal, effect) {
|
||||
if (effect.stat !== undefined || effect.sector !== undefined) {
|
||||
const found = groupOf(effect, state);
|
||||
if (!found) return false;
|
||||
const [group, key] = found;
|
||||
if (effect.bias) {
|
||||
state.modifiers.push({ type: "bias", group, key, value: effect.delta, remaining: effect.days ?? null });
|
||||
} else if (effect.days > 0) {
|
||||
state.modifiers.push({ type: "shift", group, key, perDay: effect.delta / effect.days, remaining: effect.days });
|
||||
} else {
|
||||
shift(state, bal, group, key, effect.delta);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const e = state.economy;
|
||||
if (effect.revenue !== undefined) {
|
||||
if (TAX_IDS.includes(effect.revenue)) e.taxes[effect.revenue] = Math.max(0, e.taxes[effect.revenue] + effect.delta);
|
||||
else if (effect.revenue === "other") e.otherRevenue = Math.max(0, e.otherRevenue + effect.delta);
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
if (effect.oneOff !== undefined) {
|
||||
e.debt = Math.max(0, e.debt + (effect.oneOff / 100) * e.gdp);
|
||||
return true;
|
||||
}
|
||||
if (effect.extraSpending !== undefined) {
|
||||
e.extraSpending += effect.extraSpending;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start of each day: apply the spread-out shifts.
|
||||
export function applyShifts(state, bal) {
|
||||
for (const m of state.modifiers) {
|
||||
if (m.type !== "shift") continue;
|
||||
shift(state, bal, m.group, m.key, m.perDay);
|
||||
m.remaining -= 1;
|
||||
}
|
||||
state.modifiers = state.modifiers.filter((m) => m.type !== "shift" || m.remaining > 0);
|
||||
}
|
||||
|
||||
// Sum of active target offsets for one stat.
|
||||
export function biasFor(state, group, key) {
|
||||
let sum = 0;
|
||||
for (const m of state.modifiers) if (m.type === "bias" && m.group === group && m.key === key) sum += m.value;
|
||||
return sum;
|
||||
}
|
||||
|
||||
// End of each day: age timed biases.
|
||||
export function ageBias(state) {
|
||||
for (const m of state.modifiers) if (m.type === "bias" && m.remaining !== null) m.remaining -= 1;
|
||||
state.modifiers = state.modifiers.filter((m) => m.type !== "bias" || m.remaining === null || m.remaining > 0);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Budget preview for the quarterly ledger: applies a draft budget to a copy of
|
||||
// the game and runs the real daily tick forward, without noise, so the player
|
||||
// sees where debt, deficit and the main indicators are heading.
|
||||
// It assumes no new policies (future cards are unknown).
|
||||
|
||||
import { advanceDay, summarize } from "./game.js";
|
||||
import { computeFiscal, setBudget } from "./economy.js";
|
||||
|
||||
const quietCache = new WeakMap();
|
||||
|
||||
// Same balance with all daily noise switched off.
|
||||
function quietBalance(bal) {
|
||||
let q = quietCache.get(bal);
|
||||
if (!q) {
|
||||
q = structuredClone(bal);
|
||||
q.noise.indicator = 0;
|
||||
for (const def of Object.values(q.vars)) def.noise = 0;
|
||||
for (const def of Object.values(q.indicators)) if ("noise" in def) def.noise = 0;
|
||||
quietCache.set(bal, q);
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
// Returns { ok, errors, fiscal, points }. points[0] is "now with the draft applied";
|
||||
// points[i] is the state i quarters later (fewer if the term ends first).
|
||||
export function previewBudget(state, bal, draft, { quarters = 4 } = {}) {
|
||||
const sim = structuredClone(state);
|
||||
sim.pending.review = true; // the preview may edit the budget even outside a review
|
||||
const res = setBudget(sim, bal, draft);
|
||||
if (!res.ok) return { ok: false, errors: res.errors, fiscal: null, points: [] };
|
||||
|
||||
const quiet = quietBalance(bal);
|
||||
const fiscal = computeFiscal(sim, quiet);
|
||||
const points = [summarize(sim, quiet)];
|
||||
|
||||
for (let q = 0; q < quarters && !sim.over; q++) {
|
||||
for (let d = 0; d < bal.time.daysPerQuarter && !sim.over; d++) {
|
||||
for (const k of Object.keys(sim.pending)) sim.pending[k] = false;
|
||||
advanceDay(sim, quiet);
|
||||
}
|
||||
points.push(summarize(sim, quiet));
|
||||
}
|
||||
return { ok: true, errors: [], fiscal, points };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// mulberry32 PRNG. The generator state lives in state.rngState (a uint32) so a
|
||||
// saved game resumes with the exact same sequence.
|
||||
|
||||
export function nextFloat(state) {
|
||||
state.rngState = (state.rngState + 0x6d2b79f5) >>> 0;
|
||||
let t = state.rngState;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
// Uniform in [-amp, amp].
|
||||
export function nextNoise(state, amp) {
|
||||
return (nextFloat(state) * 2 - 1) * amp;
|
||||
}
|
||||
|
||||
export function nextInt(state, maxExclusive) {
|
||||
return Math.floor(nextFloat(state) * maxExclusive);
|
||||
}
|
||||
|
||||
export function pickWeighted(state, items, weights) {
|
||||
let total = 0;
|
||||
for (const w of weights) total += w;
|
||||
if (total <= 0) return null;
|
||||
let r = nextFloat(state) * total;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
r -= weights[i];
|
||||
if (r < 0) return items[i];
|
||||
}
|
||||
return items[items.length - 1];
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// End-of-term review: a score from the whole term (not just the last day), a letter
|
||||
// grade, the historian's title and the decisions that mattered most.
|
||||
// Pure: works for a finished term and for a game that ended early.
|
||||
|
||||
import { clamp } from "./state.js";
|
||||
import { indexCards } from "./cards.js";
|
||||
|
||||
// Names usable in the `min` / `max` conditions of endgame.json titles.
|
||||
export const METRIC_IDS = [
|
||||
"order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty",
|
||||
"growth", "unemployment", "inflation", "debt", "score", "years",
|
||||
];
|
||||
|
||||
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
|
||||
|
||||
// Averages over the whole term, plus the final debt ratio.
|
||||
function termAverages(state) {
|
||||
const s = state.history.series;
|
||||
const m = {};
|
||||
for (const id of ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation"]) {
|
||||
m[id] = mean(s[id] ?? []);
|
||||
}
|
||||
m.debt = s.debtRatio?.at(-1) ?? 0;
|
||||
m.years = state.day / 365;
|
||||
return m;
|
||||
}
|
||||
|
||||
function computeScore(m, bal) {
|
||||
const sc = bal.score;
|
||||
let base = 0;
|
||||
for (const [id, w] of Object.entries(sc.weights)) base += w * (id === "calm" ? 100 - m.unrest : m[id]);
|
||||
const bonus = (cfg, value) => clamp((cfg.ref - value) * cfg.per, cfg.min, cfg.max);
|
||||
const total = base
|
||||
+ bonus(sc.debt, m.debt)
|
||||
+ clamp((m.growth - sc.growth.ref) * sc.growth.per, sc.growth.min, sc.growth.max)
|
||||
+ bonus(sc.unemployment, m.unemployment);
|
||||
return clamp(total, 0, 100);
|
||||
}
|
||||
|
||||
const gradeOf = (score, bal) => bal.score.grades.find(([min]) => score >= min)?.[1] ?? "F";
|
||||
|
||||
// How strongly one effect moves the country, in rough "points". Used to rank decisions.
|
||||
export function effectImpact(e) {
|
||||
const d = Math.abs(e.delta ?? 0);
|
||||
if (e.stat !== undefined || e.sector !== undefined) {
|
||||
if (e.bias) return d * clamp((e.days ?? 365) / 90, 1, 8);
|
||||
return d;
|
||||
}
|
||||
if (e.revenue !== undefined) return d * (e.revenue === "other" ? 8 : 2);
|
||||
if (e.oneOff !== undefined) return Math.abs(e.oneOff) * 5;
|
||||
if (e.extraSpending !== undefined) return Math.abs(e.extraSpending) * 8;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const optionImpact = (option) => (option.effects ?? []).reduce((sum, e) => sum + effectImpact(e), 0);
|
||||
|
||||
function topDecisions(state, cards, count) {
|
||||
const byId = indexCards(cards);
|
||||
return state.cardHistory
|
||||
.map((h) => {
|
||||
const card = byId.get(h.id);
|
||||
return card ? { id: h.id, day: h.day, choice: h.choice, impact: optionImpact(card[h.choice]) } : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.impact - a.impact || a.day - b.day)
|
||||
.slice(0, count);
|
||||
}
|
||||
|
||||
function meets(entry, m) {
|
||||
const c = entry.conditions ?? {};
|
||||
for (const [k, v] of Object.entries(c.min ?? {})) if (!(m[k] >= v)) return false;
|
||||
for (const [k, v] of Object.entries(c.max ?? {})) if (!(m[k] <= v)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// endgame: the parsed data/endgame.json (its `titles` list is in priority order).
|
||||
export function buildReview(state, bal, cards, endgame) {
|
||||
const metrics = termAverages(state);
|
||||
let score = computeScore(metrics, bal);
|
||||
if (state.over && state.over.type !== "term") score = Math.min(score, bal.score.overCap);
|
||||
metrics.score = score;
|
||||
|
||||
const earlyEnd = state.over && state.over.type !== "term" ? state.over.type : null;
|
||||
const title = endgame.titles.find((t) => (t.over ?? null) === earlyEnd && (earlyEnd || meets(t, metrics)));
|
||||
return {
|
||||
over: state.over,
|
||||
metrics,
|
||||
score,
|
||||
grade: earlyEnd ? "F" : gradeOf(score, bal),
|
||||
titleId: title?.id ?? null,
|
||||
topDecisions: topDecisions(state, cards, bal.score.topDecisions),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Sector quality drifts toward a target set by the budget ratio (alloc / need).
|
||||
// Each sector has its own time constant: police reacts in weeks, infrastructure in years.
|
||||
|
||||
import { clamp, qualityFromRatio } from "./state.js";
|
||||
import { biasFor } from "./modifiers.js";
|
||||
|
||||
export function stepSectors(state, bal, fiscal) {
|
||||
const s = bal.sector;
|
||||
for (const [id, sec] of Object.entries(state.sectors)) {
|
||||
let target = qualityFromRatio(sec.alloc / sec.need, bal) + biasFor(state, "sector", id);
|
||||
if (id === "industry" && fiscal.debtRatio > s.industryDebtPenaltyFrom) {
|
||||
target -= s.industryDebtPenaltyPerPoint * (fiscal.debtRatio - s.industryDebtPenaltyFrom);
|
||||
}
|
||||
sec.quality = clamp(sec.quality + (clamp(target, 0, 100) - sec.quality) / bal.sectorTau[id], 0, 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Shared constants and tiny helpers. No game logic here.
|
||||
|
||||
export const STATE_VERSION = 1;
|
||||
|
||||
export const IND_IDS = [
|
||||
"order", "stability", "civilRights", "opinion", "economy",
|
||||
"hardPower", "softPower", "unrest", "armyLoyalty",
|
||||
];
|
||||
export const VAR_IDS = ["growth", "unemployment", "inflation"];
|
||||
export const TAX_IDS = ["income", "corporate", "vat"];
|
||||
|
||||
export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||
|
||||
// Saturating curve: overspending has diminishing returns, ratio 0 gives 0.
|
||||
export function qualityFromRatio(ratio, bal) {
|
||||
return 100 * (1 - Math.exp(-bal.sector.qualityCurve * Math.max(0, ratio)));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Real-time clock accumulator and in-game calendar helpers.
|
||||
|
||||
export function createClock() {
|
||||
return { acc: 0 };
|
||||
}
|
||||
|
||||
// Feed it the real elapsed ms; it returns how many in-game days are due.
|
||||
// dt is capped so a background tab doesn't fast-forward the game on return, and
|
||||
// floored at 0: the first animation frame can carry a timestamp slightly before
|
||||
// the one the caller recorded, and a negative step would pre-load a whole day.
|
||||
export function consumeClock(clock, dtMs, msPerDay, speed) {
|
||||
clock.acc += Math.max(0, Math.min(dtMs, 1000)) * speed;
|
||||
const days = Math.floor(clock.acc / msPerDay);
|
||||
clock.acc -= days * msPerDay;
|
||||
return days;
|
||||
}
|
||||
|
||||
export function dateOfDay(startDate, day) {
|
||||
const [y, m, d] = startDate.split("-").map(Number);
|
||||
const date = new Date(Date.UTC(y, m - 1, d + day));
|
||||
return {
|
||||
year: date.getUTCFullYear(),
|
||||
month: date.getUTCMonth() + 1,
|
||||
day: date.getUTCDate(),
|
||||
weekday: date.getUTCDay(), // 0 = Sunday
|
||||
iso: date.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
export const weekOfDay = (day, bal) => Math.floor(day / bal.time.daysPerWeek);
|
||||
export const quarterOfDay = (day, bal) => Math.floor(day / bal.time.daysPerQuarter);
|
||||
@@ -0,0 +1,96 @@
|
||||
// Checks a game loaded from a save (autosave, slot or imported string) before the game
|
||||
// resumes from it, and repairs what can be repaired. A hostile or damaged save must never
|
||||
// get as far as the UI: every number is finite and in range, every id is known.
|
||||
|
||||
import { STATE_VERSION, IND_IDS, VAR_IDS, TAX_IDS } from "./state.js";
|
||||
import { PRONOUNS } from "./game.js";
|
||||
|
||||
const DEV_KEYS = ["growth", "unemployment", "inflation", "debt", "deficit", "taxPressure", "taxGrowthDrag", "crackdown"];
|
||||
const HISTORY_SERIES = ["debtRatio", "deficit", ...IND_IDS, ...VAR_IDS];
|
||||
const OVER_TYPES = ["term", "revolution", "coup", "default", "impeachment"];
|
||||
const ALERT_KINDS = ["revolution", "coup", "impeachment", "default"];
|
||||
const PENDING_KINDS = ["profile", "weeklyCard", "review", "alert"];
|
||||
|
||||
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
const isNum = (v, lo = -Infinity, hi = Infinity) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
|
||||
const isInt = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi;
|
||||
const sameKeys = (obj, keys) => isObj(obj) && Object.keys(obj).length === keys.length && keys.every((k) => k in obj);
|
||||
const allNums = (obj, keys, lo, hi) => sameKeys(obj, keys) && keys.every((k) => isNum(obj[k], lo, hi));
|
||||
|
||||
// Returns { ok: true, state } (repaired in place) or { ok: false, error: "invalid" | "scenario" | "version" }.
|
||||
export function restoreState(s, { scenarios, balance, cards }) {
|
||||
const fail = (error = "invalid") => ({ ok: false, error });
|
||||
if (!isObj(s) || s.version !== STATE_VERSION) return fail("version");
|
||||
const scenario = scenarios[s.scenarioId];
|
||||
if (!scenario) return fail("scenario");
|
||||
|
||||
const lim = balance.limits;
|
||||
const cardIds = new Set(cards.map((c) => c.id));
|
||||
const sectorIds = Object.keys(scenario.sectors);
|
||||
const modGroups = { ind: IND_IDS, var: VAR_IDS, sector: sectorIds };
|
||||
|
||||
// Scalars
|
||||
if (!isInt(s.day, 0, balance.time.termDays)) return fail();
|
||||
if (!isInt(s.rngState, 0, 0xffffffff) || !isInt(s.seed, 0, 0xffffffff)) return fail();
|
||||
if (s.speed !== 0 && !balance.time.speeds.includes(s.speed)) return fail();
|
||||
if (s.startDate !== scenario.startDate) return fail();
|
||||
if (!PRONOUNS.includes(s.pronouns)) return fail();
|
||||
if (typeof s.countryName !== "string" || s.countryName.length < 1 || s.countryName.length > 60) return fail();
|
||||
if (s.lang !== "en" && s.lang !== "fr") s.lang = "en";
|
||||
|
||||
// Economy, sectors, indicators
|
||||
const e = s.economy;
|
||||
if (!isObj(e) || !isNum(e.gdp, 1e-6) || !isNum(e.debt, 0) || !isNum(e.otherRevenue) || !isNum(e.extraSpending)) return fail();
|
||||
if (!isNum(e.growth, -100, 100) || !isNum(e.unemployment, 0, 100) || !isNum(e.inflation, -100, 1000)) return fail();
|
||||
if (!allNums(e.taxes, TAX_IDS, 0, 500)) return fail();
|
||||
|
||||
if (!sameKeys(s.sectors, sectorIds)) return fail();
|
||||
for (const id of sectorIds) {
|
||||
const x = s.sectors[id];
|
||||
if (!isObj(x) || !isNum(x.need, 1e-6) || !isNum(x.alloc, 0, x.need * lim.allocMaxRatio + 1e-9) || !isNum(x.quality, 0, 100)) return fail();
|
||||
}
|
||||
if (!allNums(s.indicators, IND_IDS, 0, 100)) return fail();
|
||||
|
||||
// Reference values captured at the start of the game
|
||||
const r = s.ref;
|
||||
if (!isObj(r) || !allNums(r.taxes, TAX_IDS, 0, 500) || !allNums(r.ind, IND_IDS, 0, 100)) return fail();
|
||||
if (!allNums(r.vars, VAR_IDS) || !allNums(r.sectors, sectorIds) || !allNums(r.ratio, sectorIds) || !allNums(r.dev, DEV_KEYS)) return fail();
|
||||
|
||||
// Timed modifiers
|
||||
if (!Array.isArray(s.modifiers)) return fail();
|
||||
for (const m of s.modifiers) {
|
||||
if (!isObj(m) || !(m.group in modGroups) || !modGroups[m.group].includes(m.key)) return fail();
|
||||
if (m.type === "shift" ? !(isNum(m.perDay) && isNum(m.remaining, 1)) : !(m.type === "bias" && isNum(m.value) && (m.remaining === null || isNum(m.remaining, 1)))) return fail();
|
||||
}
|
||||
|
||||
// Decisions and flags
|
||||
if (!isObj(s.flags) || !Object.values(s.flags).every((v) => isNum(v))) return fail();
|
||||
if (!Array.isArray(s.cardHistory)) return fail();
|
||||
for (const h of s.cardHistory) {
|
||||
if (!isObj(h) || typeof h.id !== "string" || typeof h.category !== "string" || !isInt(h.day, 0, balance.time.termDays) || (h.choice !== "yes" && h.choice !== "no")) return fail();
|
||||
}
|
||||
if (!Array.isArray(s.followUps) || !s.followUps.every((f) => isObj(f) && typeof f.id === "string" && isNum(f.due))) return fail();
|
||||
if (s.pendingCard !== null && !(isObj(s.pendingCard) && typeof s.pendingCard.id === "string" && isInt(s.pendingCard.day, 0, balance.time.termDays))) return fail();
|
||||
|
||||
// Pauses, alerts and endings
|
||||
if (!isObj(s.pending)) return fail();
|
||||
for (const k of PENDING_KINDS) s.pending[k] = s.pending[k] === true;
|
||||
if (!Array.isArray(s.alerts) || !s.alerts.every((a) => isObj(a) && ALERT_KINDS.includes(a.kind) && isInt(a.day, 0, balance.time.termDays))) return fail();
|
||||
if (!isObj(s.risk) || ![s.risk.revolution, s.risk.coup, s.risk.impeachment].every((v) => isNum(v, 0)) || typeof s.risk.defaultZone !== "boolean") return fail();
|
||||
if (s.over !== null && !(isObj(s.over) && OVER_TYPES.includes(s.over.type) && isInt(s.over.day, 0, balance.time.termDays))) return fail();
|
||||
|
||||
// History for the charts: one equal-length series per tracked value
|
||||
const h = s.history;
|
||||
if (!isObj(h) || !Array.isArray(h.t) || !isObj(h.series) || !h.t.every((d) => isInt(d, 0, balance.time.termDays))) return fail();
|
||||
for (const id of HISTORY_SERIES) {
|
||||
const series = h.series[id];
|
||||
if (!Array.isArray(series) || series.length !== h.t.length || !series.every((v) => isNum(v))) return fail();
|
||||
}
|
||||
if (!Array.isArray(s.log)) return fail();
|
||||
|
||||
// Repairs: content that changed since the save was made
|
||||
s.followUps = s.followUps.filter((f) => cardIds.has(f.id));
|
||||
if (s.pendingCard && !cardIds.has(s.pendingCard.id)) s.pendingCard = null;
|
||||
s.pending.alert = s.alerts.length > 0;
|
||||
return { ok: true, state: s };
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"app.title": "The Desk",
|
||||
"dash.title": "Ministerial Briefing",
|
||||
"dash.confidential": "Strictly confidential",
|
||||
"dash.econ": "Economic figures",
|
||||
"dash.intel": "Intelligence summary",
|
||||
"dash.classified": "Classified",
|
||||
"ind.order": "Order",
|
||||
"ind.stability": "Stability",
|
||||
"ind.civilRights": "Civil rights",
|
||||
"ind.opinion": "Public opinion",
|
||||
"ind.economy": "Economy",
|
||||
"ind.hardPower": "Hard power",
|
||||
"ind.softPower": "Soft power",
|
||||
"ind.unrest": "Unrest",
|
||||
"ind.armyLoyalty": "Army loyalty",
|
||||
"opinion.0": "Angry",
|
||||
"opinion.1": "Hostile",
|
||||
"opinion.2": "Uneasy",
|
||||
"opinion.3": "Neutral",
|
||||
"opinion.4": "Content",
|
||||
"opinion.5": "Happy",
|
||||
"opinion.6": "Ecstatic",
|
||||
"unrest.0": "Calm",
|
||||
"unrest.1": "Restless",
|
||||
"unrest.2": "Tense",
|
||||
"unrest.3": "Volatile",
|
||||
"unrest.4": "Explosive",
|
||||
"army.0": "Mutinous",
|
||||
"army.1": "Wavering",
|
||||
"army.2": "Steady",
|
||||
"army.3": "Loyal",
|
||||
"econ.gdp": "GDP",
|
||||
"econ.growth": "Growth",
|
||||
"econ.unemployment": "Unemployment",
|
||||
"econ.inflation": "Inflation",
|
||||
"econ.debt": "Debt / GDP",
|
||||
"econ.deficit": "Deficit / GDP",
|
||||
"econ.rate": "Interest rate",
|
||||
"econ.gdpUnit": "bn {currency}",
|
||||
"cal.dayCount": "Day {day} of {total}",
|
||||
"cal.briefing": "Next briefing",
|
||||
"cal.review": "Budget review",
|
||||
"cal.inDays": "in {n} days",
|
||||
"cal.today": "today",
|
||||
"cal.tomorrow": "tomorrow",
|
||||
"cal.pause": "Pause",
|
||||
"cal.speed": "Speed {n}×",
|
||||
"inbox.title": "In-tray",
|
||||
"inbox.idle": "Nothing awaits your signature. The country carries on.",
|
||||
"inbox.overTitle": "End of term",
|
||||
"inbox.overBody": "Your term has ended. (The final review arrives in a later build.)",
|
||||
"news.edition": "Edition of {date}",
|
||||
"sector.education": "Education",
|
||||
"sector.health": "Health",
|
||||
"sector.social": "Social & pensions",
|
||||
"sector.police": "Police",
|
||||
"sector.army": "Army",
|
||||
"sector.justice": "Justice",
|
||||
"sector.industry": "Industry & energy",
|
||||
"sector.transport": "Public transport",
|
||||
"sector.infrastructure": "Infrastructure",
|
||||
"sector.housing": "Housing",
|
||||
"sector.research": "Research",
|
||||
"sector.culture": "Culture & media",
|
||||
"sector.foreignAffairs": "Foreign affairs",
|
||||
"sector.environment": "Environment",
|
||||
"sector.administration": "Administration",
|
||||
"tax.income": "Income tax",
|
||||
"tax.corporate": "Corporate tax",
|
||||
"tax.vat": "VAT",
|
||||
"ledger.title": "Treasury ledger",
|
||||
"ledger.spending": "Spending, % of GDP",
|
||||
"ledger.spendingHint": "The tick on each line marks what the sector needs.",
|
||||
"ledger.totalAlloc": "Allocated to sectors",
|
||||
"ledger.revenue": "Revenue: tax rates",
|
||||
"ledger.revenueHint": "The tick marks the rate where revenue peaks. Beyond it, you collect less.",
|
||||
"ledger.yield": "{n} % of GDP",
|
||||
"ledger.quality": "Quality {now} → {target}",
|
||||
"ledger.summary": "Treasury summary",
|
||||
"ledger.sumRevenue": "Revenue",
|
||||
"ledger.sumSectors": "Sector spending",
|
||||
"ledger.sumInterest": "Debt interest",
|
||||
"ledger.sumSpending": "Total spending",
|
||||
"ledger.sumSurplus": "Surplus",
|
||||
"ledger.sumDeficit": "Deficit",
|
||||
"ledger.perYear": "{n} bn {currency} per year",
|
||||
"ledger.projection": "Next four quarters, if nothing else changes",
|
||||
"ledger.now": "Now",
|
||||
"ledger.months": "+{n} mo",
|
||||
"ledger.reset": "Reset",
|
||||
"ledger.confirm": "Sign and file",
|
||||
"ledger.error": "The Treasury rejected this budget.",
|
||||
"kind.memo": "Memorandum",
|
||||
"kind.telegram": "Telegram",
|
||||
"kind.petition": "Petition",
|
||||
"kind.report": "Report",
|
||||
"kind.letter": "Letter",
|
||||
"card.approved": "Approved",
|
||||
"card.rejected": "Rejected",
|
||||
"card.keys": "Keys: Y approve · N reject",
|
||||
"card.from": "From",
|
||||
"profile.title": "Sworn in",
|
||||
"profile.lead": "Before you take office, the Press Office needs one thing for the official records.",
|
||||
"profile.body": "How should the press refer to you?",
|
||||
"profile.he": "He / Him",
|
||||
"profile.she": "She / Her",
|
||||
"profile.they": "They / Them",
|
||||
"profile.note": "This only changes how you are named in the history books.",
|
||||
"alert.urgent": "Urgent telegram",
|
||||
"alert.ack": "Understood",
|
||||
"ind.regime": "Hold on power",
|
||||
"regime.0": "Secure",
|
||||
"regime.1": "Shaky",
|
||||
"regime.2": "Critical",
|
||||
"end.verdict": "The historians' verdict",
|
||||
"end.score": "Score {n} / 100",
|
||||
"end.grade": "Grade",
|
||||
"end.served": "Served {days} days",
|
||||
"end.decisions": "Decisions that defined the term",
|
||||
"end.noDecisions": "No decision was signed.",
|
||||
"end.chose": "You chose: {label}",
|
||||
"end.again": "Play again",
|
||||
"end.averages": "avg",
|
||||
"end.final": "end",
|
||||
"cal.menu": "Menu",
|
||||
"menu.secret": "Top secret",
|
||||
"menu.tagline": "A country. Five years. Every decision carries your signature.",
|
||||
"menu.paused": "Game paused",
|
||||
"menu.continue": "Continue",
|
||||
"menu.resume": "Resume",
|
||||
"menu.new": "New game",
|
||||
"menu.load": "Load game",
|
||||
"menu.save": "Save game",
|
||||
"menu.export": "Export",
|
||||
"menu.import": "Import",
|
||||
"menu.settings": "Settings",
|
||||
"menu.credits": "Credits",
|
||||
"menu.quit": "Main menu",
|
||||
"menu.back": "Back",
|
||||
"menu.info": "{country} · day {day}",
|
||||
"new.title": "New game",
|
||||
"new.name": "Name of the country",
|
||||
"new.scenario": "Scenario",
|
||||
"new.pronouns": "How should the press refer to you?",
|
||||
"new.start": "Take office",
|
||||
"load.title": "Load game",
|
||||
"load.auto": "Autosave",
|
||||
"load.slot": "Slot {n}",
|
||||
"load.empty": "Empty",
|
||||
"load.info": "{country} · day {day}{date}",
|
||||
"load.finished": "finished",
|
||||
"load.unreadable": "Unreadable save",
|
||||
"load.load": "Load",
|
||||
"load.delete": "Delete",
|
||||
"load.sure": "Sure?",
|
||||
"load.replace": "Loading replaces the game in progress.",
|
||||
"save.title": "Save game",
|
||||
"save.here": "Save here",
|
||||
"save.overwrite": "Overwrite",
|
||||
"save.done": "Saved to slot {n}.",
|
||||
"save.failed": "The browser refused the save. Use Export to keep your game.",
|
||||
"export.title": "Export",
|
||||
"export.hint": "Copy this text or download the file. You can import it later, on any device.",
|
||||
"export.copy": "Copy",
|
||||
"export.copied": "Copied.",
|
||||
"export.copyFailed": "Select the text and copy it by hand.",
|
||||
"export.download": "Download .sav",
|
||||
"import.title": "Import",
|
||||
"import.hint": "Paste a save string, or choose a .sav file.",
|
||||
"import.file": "Choose a file",
|
||||
"import.go": "Import",
|
||||
"import.error.format": "That does not look like a save string.",
|
||||
"import.error.checksum": "The save is damaged or incomplete (checksum mismatch).",
|
||||
"import.error.tooNew": "This save comes from a newer version of the game.",
|
||||
"import.error.version": "This save is from a version that cannot be converted.",
|
||||
"import.error.invalid": "The contents of this save are not valid.",
|
||||
"import.error.scenario": "This save uses a scenario that is not installed.",
|
||||
"import.error.empty": "Nothing to import yet.",
|
||||
"settings.title": "Settings",
|
||||
"settings.language": "Language",
|
||||
"settings.slow": "Slow time",
|
||||
"settings.slowHint": "One in-game day takes 60 seconds instead of 10.",
|
||||
"settings.storage": "This browser is not keeping saves between visits (private window or blocked storage). Use Export to keep your game.",
|
||||
"credits.title": "Credits",
|
||||
"credits.fiction": "Valdoria and everyone in it are fictional.",
|
||||
"credits.tech": "Built with Go and plain JavaScript, with no third-party libraries.",
|
||||
"credits.fonts": "Fonts: Special Elite (Apache License 2.0), IM Fell English and Courier Prime (SIL Open Font License). The licence files are shipped with the game."
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"app.title": "Le Bureau",
|
||||
"dash.title": "Note ministérielle",
|
||||
"dash.confidential": "Strictement confidentiel",
|
||||
"dash.econ": "Chiffres économiques",
|
||||
"dash.intel": "Synthèse du renseignement",
|
||||
"dash.classified": "Classifié",
|
||||
"ind.order": "Ordre public",
|
||||
"ind.stability": "Stabilité",
|
||||
"ind.civilRights": "Droits civiques",
|
||||
"ind.opinion": "Opinion publique",
|
||||
"ind.economy": "Économie",
|
||||
"ind.hardPower": "Puissance militaire",
|
||||
"ind.softPower": "Influence",
|
||||
"ind.unrest": "Agitation",
|
||||
"ind.armyLoyalty": "Loyauté de l'armée",
|
||||
"opinion.0": "En colère",
|
||||
"opinion.1": "Hostile",
|
||||
"opinion.2": "Inquiète",
|
||||
"opinion.3": "Neutre",
|
||||
"opinion.4": "Satisfaite",
|
||||
"opinion.5": "Heureuse",
|
||||
"opinion.6": "Extatique",
|
||||
"unrest.0": "Calme",
|
||||
"unrest.1": "Agité",
|
||||
"unrest.2": "Tendu",
|
||||
"unrest.3": "Volatil",
|
||||
"unrest.4": "Explosif",
|
||||
"army.0": "Mutinée",
|
||||
"army.1": "Hésitante",
|
||||
"army.2": "Stable",
|
||||
"army.3": "Loyale",
|
||||
"econ.gdp": "PIB",
|
||||
"econ.growth": "Croissance",
|
||||
"econ.unemployment": "Chômage",
|
||||
"econ.inflation": "Inflation",
|
||||
"econ.debt": "Dette / PIB",
|
||||
"econ.deficit": "Déficit / PIB",
|
||||
"econ.rate": "Taux d'intérêt",
|
||||
"econ.gdpUnit": "Md {currency}",
|
||||
"cal.dayCount": "Jour {day} sur {total}",
|
||||
"cal.briefing": "Prochain dossier",
|
||||
"cal.review": "Revue budgétaire",
|
||||
"cal.inDays": "dans {n} jours",
|
||||
"cal.today": "aujourd'hui",
|
||||
"cal.tomorrow": "demain",
|
||||
"cal.pause": "Pause",
|
||||
"cal.speed": "Vitesse {n}×",
|
||||
"inbox.title": "Corbeille d'arrivée",
|
||||
"inbox.idle": "Rien n'attend votre signature. Le pays poursuit sa route.",
|
||||
"inbox.overTitle": "Fin de mandat",
|
||||
"inbox.overBody": "Votre mandat est terminé. (Le bilan final arrive dans une prochaine version.)",
|
||||
"news.edition": "Édition du {date}",
|
||||
"sector.education": "Éducation",
|
||||
"sector.health": "Santé",
|
||||
"sector.social": "Social, retraites",
|
||||
"sector.police": "Police",
|
||||
"sector.army": "Armée",
|
||||
"sector.justice": "Justice",
|
||||
"sector.industry": "Industrie, énergie",
|
||||
"sector.transport": "Transports publics",
|
||||
"sector.infrastructure": "Infrastructures",
|
||||
"sector.housing": "Logement",
|
||||
"sector.research": "Recherche",
|
||||
"sector.culture": "Culture, médias",
|
||||
"sector.foreignAffairs": "Diplomatie",
|
||||
"sector.environment": "Environnement",
|
||||
"sector.administration": "Administration",
|
||||
"tax.income": "Impôt revenu",
|
||||
"tax.corporate": "Impôt sociétés",
|
||||
"tax.vat": "TVA",
|
||||
"ledger.title": "Registre du Trésor",
|
||||
"ledger.spending": "Dépenses, % du PIB",
|
||||
"ledger.spendingHint": "Le repère sur chaque ligne marque le besoin du secteur.",
|
||||
"ledger.totalAlloc": "Alloué aux secteurs",
|
||||
"ledger.revenue": "Recettes : taux d'imposition",
|
||||
"ledger.revenueHint": "Le repère marque le taux où les recettes sont maximales. Au-delà, vous collectez moins.",
|
||||
"ledger.yield": "{n} % du PIB",
|
||||
"ledger.quality": "Qualité {now} → {target}",
|
||||
"ledger.summary": "Synthèse du Trésor",
|
||||
"ledger.sumRevenue": "Recettes",
|
||||
"ledger.sumSectors": "Dépenses sectorielles",
|
||||
"ledger.sumInterest": "Intérêts de la dette",
|
||||
"ledger.sumSpending": "Dépenses totales",
|
||||
"ledger.sumSurplus": "Excédent",
|
||||
"ledger.sumDeficit": "Déficit",
|
||||
"ledger.perYear": "{n} Md {currency} par an",
|
||||
"ledger.projection": "Quatre prochains trimestres, sans autre changement",
|
||||
"ledger.now": "Auj.",
|
||||
"ledger.months": "+{n} m",
|
||||
"ledger.reset": "Réinitialiser",
|
||||
"ledger.confirm": "Signer et classer",
|
||||
"ledger.error": "Le Trésor a rejeté ce budget.",
|
||||
"kind.memo": "Mémorandum",
|
||||
"kind.telegram": "Télégramme",
|
||||
"kind.petition": "Pétition",
|
||||
"kind.report": "Rapport",
|
||||
"kind.letter": "Lettre",
|
||||
"card.approved": "Approuvé",
|
||||
"card.rejected": "Rejeté",
|
||||
"card.keys": "Touches : Y approuver · N rejeter",
|
||||
"card.from": "De la part de",
|
||||
"profile.title": "Prestation de serment",
|
||||
"profile.lead": "Avant votre entrée en fonction, le service de presse a besoin d'une précision pour les registres officiels.",
|
||||
"profile.body": "Comment la presse doit-elle s'adresser à vous ?",
|
||||
"profile.he": "Il / Lui",
|
||||
"profile.she": "Elle / Elle",
|
||||
"profile.they": "Iel / Ielle",
|
||||
"profile.note": "Cela ne change que la manière dont vous serez nommé dans les livres d'histoire.",
|
||||
"alert.urgent": "Télégramme urgent",
|
||||
"alert.ack": "Compris",
|
||||
"ind.regime": "Emprise sur le pouvoir",
|
||||
"regime.0": "Solide",
|
||||
"regime.1": "Fragile",
|
||||
"regime.2": "Critique",
|
||||
"end.verdict": "Le verdict des historiens",
|
||||
"end.score": "Score {n} / 100",
|
||||
"end.grade": "Note",
|
||||
"end.served": "{days} jours au pouvoir",
|
||||
"end.decisions": "Les décisions qui ont marqué le mandat",
|
||||
"end.noDecisions": "Aucune décision n'a été signée.",
|
||||
"end.chose": "Votre choix : {label}",
|
||||
"end.again": "Rejouer",
|
||||
"end.averages": "moy.",
|
||||
"end.final": "fin",
|
||||
"cal.menu": "Menu",
|
||||
"menu.secret": "Secret défense",
|
||||
"menu.tagline": "Un pays. Cinq ans. Chaque décision porte votre signature.",
|
||||
"menu.paused": "Partie en pause",
|
||||
"menu.continue": "Continuer",
|
||||
"menu.resume": "Reprendre",
|
||||
"menu.new": "Nouvelle partie",
|
||||
"menu.load": "Charger une partie",
|
||||
"menu.save": "Sauvegarder",
|
||||
"menu.export": "Exporter",
|
||||
"menu.import": "Importer",
|
||||
"menu.settings": "Réglages",
|
||||
"menu.credits": "Crédits",
|
||||
"menu.quit": "Menu principal",
|
||||
"menu.back": "Retour",
|
||||
"menu.info": "{country} · jour {day}",
|
||||
"new.title": "Nouvelle partie",
|
||||
"new.name": "Nom du pays",
|
||||
"new.scenario": "Scénario",
|
||||
"new.pronouns": "Comment la presse doit-elle s'adresser à vous ?",
|
||||
"new.start": "Prendre ses fonctions",
|
||||
"load.title": "Charger une partie",
|
||||
"load.auto": "Sauvegarde auto",
|
||||
"load.slot": "Emplacement {n}",
|
||||
"load.empty": "Vide",
|
||||
"load.info": "{country} · jour {day}{date}",
|
||||
"load.finished": "terminée",
|
||||
"load.unreadable": "Sauvegarde illisible",
|
||||
"load.load": "Charger",
|
||||
"load.delete": "Supprimer",
|
||||
"load.sure": "Sûr ?",
|
||||
"load.replace": "Charger remplace la partie en cours.",
|
||||
"save.title": "Sauvegarder",
|
||||
"save.here": "Sauvegarder ici",
|
||||
"save.overwrite": "Écraser",
|
||||
"save.done": "Sauvegardé dans l'emplacement {n}.",
|
||||
"save.failed": "Le navigateur a refusé la sauvegarde. Utilisez Exporter pour garder votre partie.",
|
||||
"export.title": "Exporter",
|
||||
"export.hint": "Copiez ce texte ou téléchargez le fichier. Vous pourrez l'importer plus tard, sur n'importe quel appareil.",
|
||||
"export.copy": "Copier",
|
||||
"export.copied": "Copié.",
|
||||
"export.copyFailed": "Sélectionnez le texte et copiez-le à la main.",
|
||||
"export.download": "Télécharger le .sav",
|
||||
"import.title": "Importer",
|
||||
"import.hint": "Collez une chaîne de sauvegarde, ou choisissez un fichier .sav.",
|
||||
"import.file": "Choisir un fichier",
|
||||
"import.go": "Importer",
|
||||
"import.error.format": "Cela ne ressemble pas à une chaîne de sauvegarde.",
|
||||
"import.error.checksum": "La sauvegarde est endommagée ou incomplète (somme de contrôle invalide).",
|
||||
"import.error.tooNew": "Cette sauvegarde vient d'une version plus récente du jeu.",
|
||||
"import.error.version": "Cette sauvegarde vient d'une version qui ne peut pas être convertie.",
|
||||
"import.error.invalid": "Le contenu de cette sauvegarde n'est pas valide.",
|
||||
"import.error.scenario": "Cette sauvegarde utilise un scénario qui n'est pas installé.",
|
||||
"import.error.empty": "Rien à importer pour l'instant.",
|
||||
"settings.title": "Réglages",
|
||||
"settings.language": "Langue",
|
||||
"settings.slow": "Temps ralenti",
|
||||
"settings.slowHint": "Un jour en jeu dure 60 secondes au lieu de 10.",
|
||||
"settings.storage": "Ce navigateur ne conserve pas les sauvegardes entre deux visites (fenêtre privée ou stockage bloqué). Utilisez Exporter pour garder votre partie.",
|
||||
"credits.title": "Crédits",
|
||||
"credits.fiction": "La Valdorie et tous ses habitants sont fictifs.",
|
||||
"credits.tech": "Réalisé avec Go et du JavaScript pur, sans bibliothèque tierce.",
|
||||
"credits.fonts": "Polices : Special Elite (licence Apache 2.0), IM Fell English et Courier Prime (licence SIL Open Font). Les fichiers de licence sont fournis avec le jeu."
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// UI strings live in en.json / fr.json. Content fields (cards, newspaper) are
|
||||
// { en, fr } objects read through tx().
|
||||
|
||||
const dict = {};
|
||||
let lang = "en";
|
||||
|
||||
export async function initI18n(initial = "en") {
|
||||
for (const l of ["en", "fr"]) {
|
||||
dict[l] = await (await fetch(`js/i18n/${l}.json`)).json();
|
||||
}
|
||||
setLang(initial);
|
||||
}
|
||||
|
||||
export function setLang(l) {
|
||||
lang = dict[l] ? l : "en";
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
|
||||
export const getLang = () => lang;
|
||||
|
||||
export function t(key, vars) {
|
||||
let s = dict[lang]?.[key] ?? dict.en?.[key] ?? key;
|
||||
if (vars) for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, v);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Pick the current language from a { en, fr } content field.
|
||||
export const tx = (obj) => obj?.[lang] ?? obj?.en ?? "";
|
||||
|
||||
// Pronoun tokens for text that refers to the player: {sub} {Sub} {obj} {pos} {is} {has} {was}
|
||||
// in English, {sub} {Sub} {tonic} {e} (agreement ending) in French. Text that needs none of
|
||||
// them should avoid pronouns altogether.
|
||||
const PRONOUN_FORMS = {
|
||||
en: {
|
||||
he: { sub: "he", Sub: "He", obj: "him", pos: "his", is: "is", has: "has", was: "was" },
|
||||
she: { sub: "she", Sub: "She", obj: "her", pos: "her", is: "is", has: "has", was: "was" },
|
||||
they: { sub: "they", Sub: "They", obj: "them", pos: "their", is: "are", has: "have", was: "were" },
|
||||
},
|
||||
fr: {
|
||||
he: { sub: "il", Sub: "Il", tonic: "lui", e: "" },
|
||||
she: { sub: "elle", Sub: "Elle", tonic: "elle", e: "e" },
|
||||
they: { sub: "iel", Sub: "Iel", tonic: "ielle", e: "·e" },
|
||||
},
|
||||
};
|
||||
|
||||
export function tp(text, pronouns = "they") {
|
||||
const forms = PRONOUN_FORMS[lang]?.[pronouns] ?? PRONOUN_FORMS[lang]?.they ?? {};
|
||||
return text.replace(/\{(\w+)\}/g, (match, key) => forms[key] ?? match);
|
||||
}
|
||||
|
||||
// A historian's title: English is one string, French has a form per pronoun set.
|
||||
export function tt(title, pronouns = "they") {
|
||||
const v = title?.[lang] ?? title?.en ?? "";
|
||||
return typeof v === "string" ? v : (v[pronouns] ?? v.they);
|
||||
}
|
||||
|
||||
export function fmt(n, digits = 1) {
|
||||
return new Intl.NumberFormat(lang, { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(n);
|
||||
}
|
||||
|
||||
// ISO date (YYYY-MM-DD, treated as UTC) → long localized parts.
|
||||
export function dateParts(iso) {
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
const f = (opts) => new Intl.DateTimeFormat(lang, { timeZone: "UTC", ...opts }).format(d);
|
||||
return {
|
||||
weekday: f({ weekday: "long" }),
|
||||
day: f({ day: "numeric" }),
|
||||
month: f({ month: "long" }),
|
||||
year: f({ year: "numeric" }),
|
||||
full: f({ dateStyle: "full" }),
|
||||
};
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
// Boot, game lifecycle (menu, new game, load, autosave), panel wiring and the real-time loop.
|
||||
//
|
||||
// Dev URL params skip the menu and start a new game at once (and never touch the real autosave):
|
||||
// ?lang=fr ?seed=N ?pronouns=he|she|they ?alloc=K (scale every sector budget by K)
|
||||
// ?skip=DAYS (fast-forward, answering cards at random) ?card=ID (show that card first)
|
||||
// ?dev (exposes window.__game) ?new (just start a game) ?menu (force the menu)
|
||||
|
||||
import {
|
||||
createGame, advanceDay, setBudget, setSpeed, setPronouns, dismissAlert, isWaiting,
|
||||
createClock, consumeClock, ensureCard, answerCard, indexCards, nextFloat,
|
||||
} from "./engine/index.js";
|
||||
import { initI18n, setLang, getLang, t } from "./i18n/i18n.js";
|
||||
import { createStorage, writeSave, loadSettings, saveSettings, KEYS } from "./save/storage.js";
|
||||
import { tilt } from "./ui/desk.js";
|
||||
import { createDashboard } from "./ui/dashboard.js";
|
||||
import { createCalendar } from "./ui/calendar.js";
|
||||
import { createNewspaper } from "./ui/newspaper.js";
|
||||
import { createDecision } from "./ui/decision.js";
|
||||
import { createLedger } from "./ui/ledger.js";
|
||||
import { createTelegram } from "./ui/telegram.js";
|
||||
import { createEndgame } from "./ui/endgame.js";
|
||||
import { createMenu } from "./ui/menu.js";
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const getJson = (url) => fetch(url).then((r) => r.json());
|
||||
|
||||
// ---- Content ----
|
||||
|
||||
const [balance, newsTpl, endgame, characters, cardIndex, scenarioIndex] = await Promise.all([
|
||||
getJson("data/balance.json"),
|
||||
getJson("data/newspaper.json"),
|
||||
getJson("data/endgame.json"),
|
||||
getJson("data/characters.json"),
|
||||
getJson("data/cards/index.json"),
|
||||
getJson("data/scenarios/index.json"),
|
||||
]);
|
||||
const cards = (await Promise.all(cardIndex.files.map((f) => getJson(`data/cards/${f}`)))).flat();
|
||||
const scenarios = Object.fromEntries(
|
||||
await Promise.all(scenarioIndex.scenarios.map(async (id) => [id, await getJson(`data/scenarios/${id}.json`)])),
|
||||
);
|
||||
const cardsById = indexCards(cards);
|
||||
const content = { scenarios, balance, cards };
|
||||
|
||||
// ---- Settings and storage ----
|
||||
|
||||
const storage = createStorage();
|
||||
let settings = loadSettings(storage);
|
||||
const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en";
|
||||
await initI18n(params.get("lang") ?? settings.lang ?? browserLang);
|
||||
|
||||
const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"];
|
||||
const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu");
|
||||
|
||||
// ---- Game and screen state ----
|
||||
|
||||
let state = null; // the running game, or null in the main menu
|
||||
let scenario = null;
|
||||
let panels = null;
|
||||
let menuOpen = true;
|
||||
let lastSpeed = 1;
|
||||
|
||||
const el = {
|
||||
dash: document.getElementById("dashboard"),
|
||||
folder: document.getElementById("folder"),
|
||||
ledger: document.getElementById("ledger"),
|
||||
alerts: document.getElementById("alerts"),
|
||||
endgame: document.getElementById("endgame"),
|
||||
cal: document.getElementById("calendar"),
|
||||
news: document.getElementById("newspaper"),
|
||||
menu: document.getElementById("menu"),
|
||||
};
|
||||
const pad = document.getElementById("pad");
|
||||
for (const id of ["dash", "folder", "cal", "news"]) tilt(el[id], id);
|
||||
|
||||
// A weekly pause needs a card; with none eligible the week is simply empty.
|
||||
function prepareWeek() {
|
||||
ensureCard(state, balance, cards);
|
||||
}
|
||||
|
||||
function autosave() {
|
||||
if (!state || devRun) return;
|
||||
if (state.over) storage.remove(KEYS.auto); // a finished game has nothing to continue
|
||||
else writeSave(storage, KEYS.auto, state);
|
||||
}
|
||||
|
||||
// ---- Panels ----
|
||||
|
||||
function build() {
|
||||
panels?.decision.destroy();
|
||||
document.title = t("app.title");
|
||||
state.lang = getLang();
|
||||
panels = {
|
||||
dash: createDashboard(el.dash, scenario),
|
||||
decision: createDecision(el.folder, { cardsById, characters, onAnswer, onProfile, keysEnabled: () => !menuOpen }),
|
||||
ledger: createLedger(el.ledger, scenario, { onConfirm: render }),
|
||||
cal: createCalendar(el.cal, balance, { onSpeed, onMenu: openMenu }),
|
||||
news: createNewspaper(el.news, newsTpl),
|
||||
alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }),
|
||||
endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }),
|
||||
};
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state) return;
|
||||
for (const p of Object.values(panels)) p.update(state, balance);
|
||||
// The ledger covers the folder and the side column; the end screen covers everything.
|
||||
pad.classList.toggle("is-ledger", Boolean(state.pending.review && !state.pending.profile && !state.over));
|
||||
pad.classList.toggle("is-endgame", Boolean(state.over));
|
||||
autosave();
|
||||
}
|
||||
|
||||
// ---- Game lifecycle ----
|
||||
|
||||
function newGame({ countryName, scenarioId = scenarioIndex.scenarios[0], pronouns }) {
|
||||
return createGame({
|
||||
scenario: scenarios[scenarioId],
|
||||
balance,
|
||||
seed: params.has("seed") ? Number(params.get("seed")) : crypto.getRandomValues(new Uint32Array(1))[0],
|
||||
lang: getLang(),
|
||||
countryName,
|
||||
pronouns,
|
||||
});
|
||||
}
|
||||
|
||||
function enterGame(next) {
|
||||
state = next;
|
||||
scenario = scenarios[state.scenarioId];
|
||||
if (params.has("dev")) window.__game = state;
|
||||
lastSpeed = state.speed || 1;
|
||||
prepareWeek();
|
||||
closeMenu();
|
||||
build();
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
if (!state) return;
|
||||
menuOpen = true;
|
||||
pad.classList.add("is-menu");
|
||||
menu.show("game");
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuOpen = false;
|
||||
pad.classList.remove("is-menu");
|
||||
menu.hide();
|
||||
clock.acc = 0;
|
||||
}
|
||||
|
||||
function quitToMenu() {
|
||||
autosave();
|
||||
panels?.decision.destroy();
|
||||
state = null;
|
||||
panels = null;
|
||||
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news"]) el[id].innerHTML = "";
|
||||
el.ledger.hidden = true;
|
||||
el.alerts.hidden = true;
|
||||
el.endgame.hidden = true;
|
||||
pad.classList.remove("is-ledger", "is-endgame");
|
||||
pad.classList.add("is-menu");
|
||||
menuOpen = true;
|
||||
menu.show("main");
|
||||
}
|
||||
|
||||
function applySettings(patch) {
|
||||
const langChanged = patch.lang && patch.lang !== getLang();
|
||||
settings = { ...settings, ...patch };
|
||||
saveSettings(storage, settings);
|
||||
if (!langChanged) return;
|
||||
setLang(patch.lang);
|
||||
document.title = t("app.title");
|
||||
if (state) build();
|
||||
menu.refresh();
|
||||
}
|
||||
|
||||
const menu = createMenu(el.menu, {
|
||||
content,
|
||||
storage,
|
||||
getState: () => state,
|
||||
getSettings: () => settings,
|
||||
handlers: {
|
||||
onStart: (options) => enterGame(newGame(options)),
|
||||
onLoadState: enterGame,
|
||||
onResume: closeMenu,
|
||||
onQuit: quitToMenu,
|
||||
onSettings: applySettings,
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Player actions ----
|
||||
|
||||
function onSpeed(speed) {
|
||||
if (speed > 0) lastSpeed = speed;
|
||||
setSpeed(state, balance, speed);
|
||||
render();
|
||||
}
|
||||
|
||||
function onAnswer(choice) {
|
||||
answerCard(state, balance, cards, choice);
|
||||
render();
|
||||
}
|
||||
|
||||
function onProfile(pronouns) {
|
||||
setPronouns(state, pronouns);
|
||||
render();
|
||||
}
|
||||
|
||||
function onDismiss() {
|
||||
dismissAlert(state);
|
||||
render();
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!state || e.target.closest?.("input, textarea, select") || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (menuOpen) return; // the menu handles its own keys
|
||||
if (e.key === "Escape" && !state.over) {
|
||||
openMenu();
|
||||
} else if (e.code === "Space") {
|
||||
e.preventDefault();
|
||||
onSpeed(state.speed > 0 ? 0 : lastSpeed);
|
||||
} else if (/^[1-9]$/.test(e.key) && balance.time.speeds[Number(e.key) - 1]) {
|
||||
onSpeed(balance.time.speeds[Number(e.key) - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
addEventListener("pagehide", autosave);
|
||||
document.addEventListener("visibilitychange", () => { if (document.hidden) autosave(); });
|
||||
|
||||
// ---- Dev shortcuts ----
|
||||
|
||||
function devGame() {
|
||||
const s = newGame({ pronouns: params.get("pronouns") ?? undefined });
|
||||
state = s; // the helpers below act on `state`
|
||||
if (params.has("alloc")) {
|
||||
const k = Number(params.get("alloc"));
|
||||
setBudget(s, balance, { alloc: Object.fromEntries(Object.entries(s.sectors).map(([id, x]) => [id, x.alloc * k])) });
|
||||
}
|
||||
for (let i = 0, n = Number(params.get("skip") ?? 0); i < n && !s.over; i++) {
|
||||
if (s.pending.profile) setPronouns(s, "they");
|
||||
s.pending.review = false;
|
||||
while (s.pending.alert) dismissAlert(s);
|
||||
prepareWeek();
|
||||
if (s.pendingCard) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
|
||||
advanceDay(s, balance);
|
||||
}
|
||||
if (cardsById.has(params.get("card"))) {
|
||||
s.pendingCard = { id: params.get("card"), day: s.day };
|
||||
s.pending.weeklyCard = true;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---- Start ----
|
||||
|
||||
const clock = createClock();
|
||||
let last = performance.now();
|
||||
|
||||
if (devRun) enterGame(devGame());
|
||||
else quitToMenu();
|
||||
|
||||
function frame(now) {
|
||||
const dt = now - last;
|
||||
last = now;
|
||||
if (state && !menuOpen && state.speed > 0 && !state.over && !isWaiting(state)) {
|
||||
const msPerDay = settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay;
|
||||
let days = consumeClock(clock, dt, msPerDay, state.speed);
|
||||
while (days-- > 0) {
|
||||
const r = advanceDay(state, balance);
|
||||
if (!r.advanced) break;
|
||||
if (r.events.weeklyCard) prepareWeek();
|
||||
render();
|
||||
if (isWaiting(state) || state.over) {
|
||||
clock.acc = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clock.acc = 0;
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
@@ -0,0 +1,81 @@
|
||||
// Save strings for export and import: "CG1:" + CRC32 of the JSON (8 hex digits) + ":" + base64(UTF-8 JSON).
|
||||
// The checksum only catches corrupted or truncated strings. It is not protection against editing:
|
||||
// anyone can decode, change and re-encode a save, and a single-player game does not need more.
|
||||
|
||||
import { STATE_VERSION } from "../engine/state.js";
|
||||
|
||||
const PREFIX = "CG1:";
|
||||
const FORMAT = /^CG1:([0-9a-f]{8}):([A-Za-z0-9+/]+={0,2})$/;
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
export function crc32(bytes) {
|
||||
let c = 0xffffffff;
|
||||
for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function toBase64(bytes) {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(text) {
|
||||
const binary = atob(text);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function encodeSave(state) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(state));
|
||||
return `${PREFIX}${crc32(bytes).toString(16).padStart(8, "0")}:${toBase64(bytes)}`;
|
||||
}
|
||||
|
||||
// Upgrades older saves one version at a time. Add an entry here whenever STATE_VERSION
|
||||
// changes: MIGRATIONS[n] turns a version n state into a version n+1 state.
|
||||
export const MIGRATIONS = {};
|
||||
|
||||
export function migrate(state) {
|
||||
if (!Number.isInteger(state?.version)) return { ok: false, error: "format" };
|
||||
if (state.version > STATE_VERSION) return { ok: false, error: "tooNew" };
|
||||
let s = state;
|
||||
while (s.version < STATE_VERSION) {
|
||||
const step = MIGRATIONS[s.version];
|
||||
if (!step) return { ok: false, error: "version" };
|
||||
s = step(s);
|
||||
}
|
||||
return { ok: true, state: s };
|
||||
}
|
||||
|
||||
// Returns { ok: true, state } or { ok: false, error: "format" | "checksum" | "tooNew" | "version" }.
|
||||
// Whitespace is ignored, so strings that were wrapped when pasted still work.
|
||||
export function decodeSave(text) {
|
||||
const match = FORMAT.exec(String(text).replace(/\s+/g, ""));
|
||||
if (!match) return { ok: false, error: "format" };
|
||||
|
||||
let bytes;
|
||||
try {
|
||||
bytes = fromBase64(match[2]);
|
||||
} catch {
|
||||
return { ok: false, error: "format" };
|
||||
}
|
||||
if (crc32(bytes).toString(16).padStart(8, "0") !== match[1]) return { ok: false, error: "checksum" };
|
||||
|
||||
let state;
|
||||
try {
|
||||
state = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch {
|
||||
return { ok: false, error: "format" };
|
||||
}
|
||||
return migrate(state);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Browser storage for the autosave, the manual slots and the settings.
|
||||
// Every access is wrapped: if localStorage is missing or throws (private window, blocked
|
||||
// site data, full quota) the game keeps working and uses memory for the rest of the session.
|
||||
|
||||
export const SLOT_COUNT = 3;
|
||||
export const KEYS = {
|
||||
auto: "cg.autosave",
|
||||
slot: (n) => `cg.slot.${n}`,
|
||||
settings: "cg.settings",
|
||||
};
|
||||
|
||||
function browserStorage() {
|
||||
try {
|
||||
const probe = "cg.probe";
|
||||
globalThis.localStorage.setItem(probe, "1");
|
||||
globalThis.localStorage.removeItem(probe);
|
||||
return globalThis.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// backend: anything with getItem/setItem/removeItem, for tests. Default: localStorage.
|
||||
export function createStorage(backend = browserStorage()) {
|
||||
const memory = new Map();
|
||||
const store = { persistent: backend !== null };
|
||||
|
||||
store.get = (key) => {
|
||||
try {
|
||||
if (backend) return backend.getItem(key);
|
||||
} catch {
|
||||
store.persistent = false;
|
||||
}
|
||||
return memory.get(key) ?? null;
|
||||
};
|
||||
|
||||
// Returns false when the write could not be made durable.
|
||||
store.set = (key, value) => {
|
||||
try {
|
||||
if (backend) {
|
||||
backend.setItem(key, value);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
store.persistent = false;
|
||||
}
|
||||
memory.set(key, value);
|
||||
return false;
|
||||
};
|
||||
|
||||
store.remove = (key) => {
|
||||
memory.delete(key);
|
||||
try {
|
||||
backend?.removeItem(key);
|
||||
} catch {
|
||||
store.persistent = false;
|
||||
}
|
||||
};
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
// ---- Saved games: { meta, state } under one key ----
|
||||
|
||||
export function writeSave(store, key, state) {
|
||||
const meta = {
|
||||
savedAt: Date.now(),
|
||||
day: state.day,
|
||||
countryName: state.countryName,
|
||||
pronouns: state.pronouns,
|
||||
scenarioId: state.scenarioId,
|
||||
over: state.over?.type ?? null,
|
||||
};
|
||||
return store.set(key, JSON.stringify({ meta, state }));
|
||||
}
|
||||
|
||||
// Returns { meta, state } or null when the slot is empty or unreadable.
|
||||
export function readSave(store, key) {
|
||||
const raw = store.get(key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.state && parsed?.meta ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
|
||||
const DEFAULT_SETTINGS = { lang: null, slow: false };
|
||||
|
||||
export function loadSettings(store) {
|
||||
try {
|
||||
const s = JSON.parse(store.get(KEYS.settings) ?? "{}");
|
||||
return {
|
||||
lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang,
|
||||
slow: s.slow === true,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(store, settings) {
|
||||
return store.set(KEYS.settings, JSON.stringify(settings));
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Desk calendar: in-game date, term progress, next decision dates, the brass speed
|
||||
// controls and the way into the menu.
|
||||
|
||||
import { t, dateParts } from "../i18n/i18n.js";
|
||||
import { dateOfDay } from "../engine/index.js";
|
||||
import { $ } from "./desk.js";
|
||||
|
||||
export function createCalendar(root, bal, { onSpeed, onMenu }) {
|
||||
const speeds = bal.time.speeds;
|
||||
root.innerHTML = `
|
||||
<div class="cal__head"><span class="cal__weekday"></span></div>
|
||||
<div class="cal__body">
|
||||
<div class="cal__day"></div>
|
||||
<div class="cal__month"></div>
|
||||
<div class="cal__count"></div>
|
||||
<div class="cal__term"><span></span></div>
|
||||
<dl class="cal__next">
|
||||
<dt>${t("cal.briefing")}</dt><dd data-next="briefing"></dd>
|
||||
<dt>${t("cal.review")}</dt><dd data-next="review"></dd>
|
||||
</dl>
|
||||
<div class="cal__controls">
|
||||
<button class="brass" data-speed="0" title="${t("cal.pause")}" aria-label="${t("cal.pause")}">II</button>
|
||||
${speeds.map((s) => `<button class="brass" data-speed="${s}" aria-label="${t("cal.speed", { n: s })}">${s}×</button>`).join("")}
|
||||
</div>
|
||||
<button class="brass cal__menu" data-menu>${t("cal.menu")}</button>
|
||||
</div>`;
|
||||
|
||||
root.addEventListener("click", (e) => {
|
||||
const b = e.target.closest("button");
|
||||
if (!b) return;
|
||||
if (b.dataset.menu !== undefined) onMenu();
|
||||
else onSpeed(Number(b.dataset.speed));
|
||||
});
|
||||
|
||||
const until = (day, every) => (every - (day % every)) % every;
|
||||
const when = (n) => {
|
||||
if (n === 0) return t("cal.today");
|
||||
return n === 1 ? t("cal.tomorrow") : t("cal.inDays", { n });
|
||||
};
|
||||
|
||||
function update(state) {
|
||||
const p = dateParts(dateOfDay(state.startDate, state.day).iso);
|
||||
$(root, ".cal__weekday").textContent = p.weekday;
|
||||
$(root, ".cal__day").textContent = p.day;
|
||||
$(root, ".cal__month").textContent = `${p.month} ${p.year}`;
|
||||
const shown = Math.min(state.day + 1, bal.time.termDays);
|
||||
$(root, ".cal__count").textContent = t("cal.dayCount", { day: shown, total: bal.time.termDays });
|
||||
$(root, ".cal__term span").style.width = `${(state.day / bal.time.termDays) * 100}%`;
|
||||
|
||||
const over = Boolean(state.over);
|
||||
$(root, '[data-next="briefing"]').textContent = over ? "–" : when(state.pending.weeklyCard ? 0 : until(state.day, bal.time.daysPerWeek));
|
||||
$(root, '[data-next="review"]').textContent = over ? "–" : when(state.pending.review ? 0 : until(state.day, bal.time.daysPerQuarter));
|
||||
|
||||
for (const b of root.querySelectorAll("button[data-speed]")) {
|
||||
b.setAttribute("aria-pressed", String(Number(b.dataset.speed) === state.speed));
|
||||
}
|
||||
}
|
||||
|
||||
return { update };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Hand-drawn SVG helpers. Output is built from numbers only, so it is safe as innerHTML.
|
||||
|
||||
// Bigger chart for the end-of-term review: weekly samples over the whole term,
|
||||
// a gridline per year and per quarter of the value range.
|
||||
export function lineChart(values, { w = 260, h = 96, min = 0, max = 100, yearEvery = 52 } = {}) {
|
||||
const pad = 3;
|
||||
const n = values.length;
|
||||
const span = max - min || 1;
|
||||
const x = (i) => pad + (n > 1 ? (i / (n - 1)) * (w - 2 * pad) : 0);
|
||||
const y = (v) => h - pad - Math.min(1, Math.max(0, (v - min) / span)) * (h - 2 * pad);
|
||||
|
||||
const grid = [];
|
||||
for (let i = yearEvery; i < n; i += yearEvery) grid.push(`<line x1="${x(i).toFixed(1)}" y1="0" x2="${x(i).toFixed(1)}" y2="${h}"/>`);
|
||||
for (const f of [0.25, 0.5, 0.75]) grid.push(`<line x1="0" y1="${(h * f).toFixed(1)}" x2="${w}" y2="${(h * f).toFixed(1)}"/>`);
|
||||
|
||||
const pts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
return `<svg class="chart" viewBox="0 0 ${w} ${h}" width="100%" preserveAspectRatio="none" aria-hidden="true">` +
|
||||
`<g class="spark__grid">${grid.join("")}</g>` +
|
||||
(n > 1 ? `<polyline class="spark__line" points="${pts}" vector-effect="non-scaling-stroke"/>` : "") +
|
||||
`</svg>`;
|
||||
}
|
||||
|
||||
// Small line chart on graph paper. `values` are plotted evenly left to right.
|
||||
export function sparkline(values, { w = 84, h = 24, min = 0, max = 100 } = {}) {
|
||||
const grid = [];
|
||||
for (let x = 12; x < w; x += 12) grid.push(`<line x1="${x}" y1="0" x2="${x}" y2="${h}"/>`);
|
||||
for (const f of [0.25, 0.5, 0.75]) grid.push(`<line x1="0" y1="${h * f}" x2="${w}" y2="${h * f}"/>`);
|
||||
|
||||
let line = "";
|
||||
const n = values.length;
|
||||
if (n > 1) {
|
||||
const span = max - min || 1;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = 2 + (i / (n - 1)) * (w - 4);
|
||||
const y = h - 2 - Math.min(1, Math.max(0, (v - min) / span)) * (h - 4);
|
||||
return [x.toFixed(1), y.toFixed(1)];
|
||||
});
|
||||
const last = pts[n - 1];
|
||||
line =
|
||||
`<polyline class="spark__line" points="${pts.map((p) => p.join(",")).join(" ")}"/>` +
|
||||
`<circle class="spark__dot" cx="${last[0]}" cy="${last[1]}" r="2"/>`;
|
||||
}
|
||||
return `<svg class="spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true">` +
|
||||
`<g class="spark__grid">${grid.join("")}</g>${line}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Ministerial briefing: seven indicator gauges with sparklines, economic figures
|
||||
// with trend arrows, and a classified intelligence summary for the hidden gauges.
|
||||
|
||||
import { t, fmt } from "../i18n/i18n.js";
|
||||
import { sparkline } from "./charts.js";
|
||||
import { $ } from "./desk.js";
|
||||
import { summarize } from "../engine/index.js";
|
||||
|
||||
const MAIN = ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower"];
|
||||
|
||||
// key, label, digits, suffix, summary field, history series, noise floor for the arrow, good when rising
|
||||
const ECON = [
|
||||
["gdp", "econ.gdp", 0, "", "gdp", null, 0, true],
|
||||
["growth", "econ.growth", 1, " %", "growth", "growth", 0.15, true],
|
||||
["unemployment", "econ.unemployment", 1, " %", "unemployment", "unemployment", 0.15, false],
|
||||
["inflation", "econ.inflation", 1, " %", "inflation", "inflation", 0.2, false],
|
||||
["debt", "econ.debt", 0, " %", "debtRatio", "debtRatio", 1, false],
|
||||
["deficit", "econ.deficit", 1, " %", "deficit", "deficit", 0.3, false],
|
||||
["rate", "econ.rate", 2, " %", "interestRate", null, 0, false],
|
||||
];
|
||||
|
||||
const bandIndex = (v, cuts) => cuts.filter((c) => v >= c).length;
|
||||
const OPINION_CUTS = [15, 30, 45, 60, 75, 90];
|
||||
const UNREST_CUTS = [20, 35, 50, 65];
|
||||
const ARMY_CUTS = [25, 45, 65];
|
||||
|
||||
const SPARK_WEEKS = 26;
|
||||
const SPARK_W = 76;
|
||||
const MIN_SPAN = 16;
|
||||
|
||||
export function createDashboard(root, scenario) {
|
||||
root.innerHTML = `
|
||||
<header class="dash__head">
|
||||
<h2>${t("dash.title")}</h2>
|
||||
<span class="stamp stamp--red">${t("dash.confidential")}</span>
|
||||
</header>
|
||||
<div class="dash__gauges">
|
||||
${MAIN.map((id) => `
|
||||
<div class="gauge" data-ind="${id}">
|
||||
<span class="gauge__label">${t("ind." + id)}<small class="gauge__band"></small></span>
|
||||
<span class="gauge__track"><span class="gauge__fill"></span><i></i><i></i><i></i></span>
|
||||
<span class="gauge__value"></span>
|
||||
<span class="gauge__spark"></span>
|
||||
</div>`).join("")}
|
||||
</div>
|
||||
<h3 class="dash__sub">${t("dash.econ")}</h3>
|
||||
<table class="econ"><tbody>
|
||||
${ECON.map(([key, label]) => `
|
||||
<tr data-econ="${key}"><th>${t(label)}</th><td class="econ__val"></td><td class="econ__trend"></td></tr>`).join("")}
|
||||
</tbody></table>
|
||||
<h3 class="dash__sub">${t("dash.intel")} <span class="stamp stamp--small">${t("dash.classified")}</span></h3>
|
||||
<ul class="intel">
|
||||
<li data-intel="unrest"><span>${t("ind.unrest")}</span><b></b></li>
|
||||
<li data-intel="armyLoyalty"><span>${t("ind.armyLoyalty")}</span><b></b></li>
|
||||
<li data-intel="regime"><span>${t("ind.regime")}</span><b></b></li>
|
||||
</ul>`;
|
||||
|
||||
const gauges = Object.fromEntries(MAIN.map((id) => [id, {
|
||||
el: $(root, `.gauge[data-ind="${id}"]`),
|
||||
fill: $(root, `.gauge[data-ind="${id}"] .gauge__fill`),
|
||||
value: $(root, `.gauge[data-ind="${id}"] .gauge__value`),
|
||||
band: $(root, `.gauge[data-ind="${id}"] .gauge__band`),
|
||||
spark: $(root, `.gauge[data-ind="${id}"] .gauge__spark`),
|
||||
}]));
|
||||
const sparkLen = {};
|
||||
|
||||
function trend(series, eps, goodUp) {
|
||||
if (!series || series.length < 2) return ["", ""];
|
||||
const cur = series[series.length - 1];
|
||||
const prev = series[Math.max(0, series.length - 5)];
|
||||
const d = cur - prev;
|
||||
if (Math.abs(d) < eps) return ["▬", "flat"];
|
||||
return [d > 0 ? "▲" : "▼", d > 0 === goodUp ? "good" : "bad"];
|
||||
}
|
||||
|
||||
function update(state, bal) {
|
||||
const sum = summarize(state, bal);
|
||||
|
||||
for (const id of MAIN) {
|
||||
const g = gauges[id];
|
||||
const v = state.indicators[id];
|
||||
g.fill.style.width = `${v}%`;
|
||||
g.el.classList.toggle("is-low", v < 30);
|
||||
g.el.classList.toggle("is-high", v >= 70);
|
||||
g.value.textContent = Math.round(v);
|
||||
if (id === "opinion") g.band.textContent = t("opinion." + bandIndex(v, OPINION_CUTS));
|
||||
|
||||
const series = state.history.series[id] ?? [];
|
||||
if (sparkLen[id] !== series.length) {
|
||||
sparkLen[id] = series.length;
|
||||
const win = series.slice(-SPARK_WEEKS);
|
||||
// Zoom on the data (at least MIN_SPAN points tall) so small trends stay visible.
|
||||
const mid = (Math.min(...win) + Math.max(...win)) / 2;
|
||||
const span = Math.max(Math.max(...win) - Math.min(...win), MIN_SPAN);
|
||||
g.spark.innerHTML = sparkline(win, { w: SPARK_W, min: mid - span / 2, max: mid + span / 2 });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, , digits, suffix, field, seriesId, eps, goodUp] of ECON) {
|
||||
const row = $(root, `[data-econ="${key}"]`);
|
||||
const val = sum[field];
|
||||
$(row, ".econ__val").textContent = key === "gdp"
|
||||
? `${fmt(val, 0)} ${t("econ.gdpUnit", { currency: scenario.currency })}`
|
||||
: `${fmt(val, digits)}${suffix}`;
|
||||
const [arrow, cls] = trend(seriesId && state.history.series[seriesId], eps, goodUp);
|
||||
const cell = $(row, ".econ__trend");
|
||||
cell.textContent = arrow;
|
||||
cell.className = `econ__trend ${cls}`;
|
||||
}
|
||||
|
||||
$(root, '[data-intel="unrest"] b').textContent = t("unrest." + bandIndex(state.indicators.unrest, UNREST_CUTS));
|
||||
$(root, '[data-intel="armyLoyalty"] b').textContent = t("army." + bandIndex(state.indicators.armyLoyalty, ARMY_CUTS));
|
||||
// Hold on power: Critical once any danger zone has been entered, Shaky when close to one.
|
||||
const i = state.indicators;
|
||||
const inZone = state.risk.revolution > 0 || state.risk.coup > 0 || state.risk.impeachment > 0 || state.risk.defaultZone;
|
||||
const close = i.unrest >= 55 || i.armyLoyalty <= 45 || i.opinion <= 30 || i.stability <= 40
|
||||
|| i.civilRights <= 20 || sum.debtRatio >= 130;
|
||||
const regime = inZone ? 2 : close ? 1 : 0;
|
||||
$(root, '[data-intel="regime"] b').textContent = t("regime." + regime);
|
||||
$(root, '[data-intel="regime"]').dataset.level = [0, 2, 3][regime];
|
||||
|
||||
$(root, '[data-intel="unrest"]').dataset.level = bandIndex(state.indicators.unrest, UNREST_CUTS);
|
||||
$(root, '[data-intel="armyLoyalty"]').dataset.level = 3 - bandIndex(state.indicators.armyLoyalty, ARMY_CUTS);
|
||||
}
|
||||
|
||||
return { update };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Centre folder: the weekly decision card as a paper document with two rubber
|
||||
// stamps. Also shows the idle and end-of-term messages.
|
||||
|
||||
import { t, tx, dateParts } from "../i18n/i18n.js";
|
||||
import { dateOfDay } from "../engine/index.js";
|
||||
import { $, esc } from "./desk.js";
|
||||
|
||||
const STAMP_MS = 750; // how long the stamp animation plays before the answer is applied
|
||||
|
||||
export function createDecision(root, { cardsById, characters, onAnswer, onProfile, keysEnabled = () => true }) {
|
||||
root.innerHTML = `
|
||||
<h2 class="inbox__tab">${t("inbox.title")}</h2>
|
||||
<div class="inbox__body"></div>`;
|
||||
const body = $(root, ".inbox__body");
|
||||
|
||||
let shown = "";
|
||||
let locked = false;
|
||||
let latest = null;
|
||||
|
||||
function choose(choice) {
|
||||
if (locked) return;
|
||||
const card = $(body, ".card");
|
||||
if (!card) return;
|
||||
locked = true;
|
||||
card.dataset.stamped = choice;
|
||||
for (const b of card.querySelectorAll("button")) b.disabled = true;
|
||||
const stamp = document.createElement("div");
|
||||
stamp.className = `card__stamp card__stamp--${choice}`;
|
||||
stamp.textContent = t(choice === "yes" ? "card.approved" : "card.rejected");
|
||||
card.append(stamp);
|
||||
setTimeout(() => onAnswer(choice), STAMP_MS);
|
||||
}
|
||||
|
||||
body.addEventListener("click", (e) => {
|
||||
const b = e.target.closest("button[data-choice]");
|
||||
if (b) choose(b.dataset.choice);
|
||||
const p = e.target.closest("button[data-pronouns]");
|
||||
if (p) onProfile(p.dataset.pronouns);
|
||||
});
|
||||
|
||||
// The panel is rebuilt on language changes and new games, so its key listener must be removable.
|
||||
const onKey = (e) => {
|
||||
if (!keysEnabled() || !latest?.pendingCard) return;
|
||||
if (latest.pending.review || latest.pending.profile || latest.pending.alert) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (e.target.closest?.("input, textarea, select")) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "y") choose("yes");
|
||||
else if (k === "n") choose("no");
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
function option(card, side) {
|
||||
const label = side === "yes" ? t("card.approved") : t("card.rejected");
|
||||
return `
|
||||
<button class="stamp-btn stamp-btn--${side}" data-choice="${side}">
|
||||
<span class="stamp-btn__label">${esc(tx(card[side].label))}</span>
|
||||
<span class="stamp-btn__ink">${esc(label)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function cardHtml(card, state) {
|
||||
const from = characters[card.sender];
|
||||
const date = dateParts(dateOfDay(state.startDate, state.pendingCard.day).iso);
|
||||
const paragraphs = tx(card.body).split("\n\n").map((p) => `<p>${esc(p)}</p>`).join("");
|
||||
return `
|
||||
<article class="card card--${esc(card.doc)}">
|
||||
<header class="card__head">
|
||||
<span class="card__kind">${t("kind." + card.doc)}</span>
|
||||
<span class="card__date">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
|
||||
</header>
|
||||
<div class="card__from">
|
||||
<span class="seal">${esc(from.seal)}</span>
|
||||
<span><b>${esc(tx(from.name))}</b><small>${esc(tx(from.role))}</small></span>
|
||||
</div>
|
||||
<h3 class="card__title">${esc(tx(card.title))}</h3>
|
||||
<div class="card__body">${paragraphs}</div>
|
||||
<div class="card__choices">${option(card, "yes")}${option(card, "no")}</div>
|
||||
<p class="card__keys">${t("card.keys")}</p>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function update(state) {
|
||||
latest = state;
|
||||
let kind = "idle";
|
||||
if (state.over) kind = "over";
|
||||
else if (state.pending.profile) kind = "profile";
|
||||
else if (state.pendingCard) kind = `card:${state.pendingCard.id}`;
|
||||
if (kind === shown) return;
|
||||
shown = kind;
|
||||
locked = false;
|
||||
body.classList.toggle("is-card", kind.startsWith("card:"));
|
||||
|
||||
if (kind === "profile") {
|
||||
body.innerHTML = `
|
||||
<h3>${t("profile.title")}</h3>
|
||||
<p>${t("profile.lead")}</p>
|
||||
<p class="profile__ask">${t("profile.body")}</p>
|
||||
<div class="profile__choices">
|
||||
${["he", "she", "they"].map((p) => `<button class="brass brass--wide" data-pronouns="${p}">${t("profile." + p)}</button>`).join("")}
|
||||
</div>
|
||||
<p class="profile__note">${t("profile.note")}</p>`;
|
||||
} else if (kind === "idle") {
|
||||
body.innerHTML = `<p class="inbox__idle">${t("inbox.idle")}</p>`;
|
||||
} else if (kind === "over") {
|
||||
body.innerHTML = `<h3>${t("inbox.overTitle")}</h3><p>${t("inbox.overBody")}</p>`;
|
||||
} else {
|
||||
body.innerHTML = cardHtml(cardsById.get(state.pendingCard.id), state);
|
||||
}
|
||||
}
|
||||
|
||||
return { update, destroy: () => document.removeEventListener("keydown", onKey) };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Desk-level helpers shared by the paper panels.
|
||||
|
||||
export function hash(str) {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < str.length; i++) h = Math.imul(h ^ str.charCodeAt(i), 16777619);
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
// Give a paper a stable slight rotation derived from its id.
|
||||
export function tilt(el, id, amp = 0.8) {
|
||||
const r = ((hash(id) % 1000) / 1000 - 0.5) * 2 * amp;
|
||||
el.style.setProperty("--rot", `${r.toFixed(2)}deg`);
|
||||
}
|
||||
|
||||
export const $ = (root, sel) => root.querySelector(sel);
|
||||
|
||||
// Escape text for use inside innerHTML templates.
|
||||
export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||||
@@ -0,0 +1,118 @@
|
||||
// End of the game: a retrospective front page. It covers both the normal end of
|
||||
// the term and each way of losing power early.
|
||||
|
||||
import { t, tx, tp, tt, getLang, dateParts, fmt } from "../i18n/i18n.js";
|
||||
import { buildReview, dateOfDay, indexCards } from "../engine/index.js";
|
||||
import { lineChart } from "./charts.js";
|
||||
import { $, esc } from "./desk.js";
|
||||
|
||||
const MIN_SPAN = 30;
|
||||
|
||||
// series id in state.history, label key, show the term average or the final value
|
||||
const CHARTS = [
|
||||
["opinion", "ind.opinion", "avg"],
|
||||
["stability", "ind.stability", "avg"],
|
||||
["economy", "ind.economy", "avg"],
|
||||
["debtRatio", "econ.debt", "final"],
|
||||
];
|
||||
|
||||
export function createEndgame(root, { newsName, endgame, cards, onAgain }) {
|
||||
const cardsById = indexCards(cards);
|
||||
|
||||
root.addEventListener("click", (e) => {
|
||||
if (e.target.closest("button[data-again]")) onAgain();
|
||||
});
|
||||
|
||||
let shown = "";
|
||||
|
||||
function chart(state, [id, label, mode], review) {
|
||||
const series = state.history.series[id] ?? [];
|
||||
const value = mode === "avg" ? review.metrics[id] : series.at(-1);
|
||||
const tag = mode === "avg" ? t("end.averages") : t("end.final");
|
||||
|
||||
// Zoom on the data (at least MIN_SPAN points tall) so trends are visible; stay within 0-100
|
||||
// for indicators, and above 0 for debt.
|
||||
const lo = Math.min(...series);
|
||||
const hi = Math.max(...series);
|
||||
const span = Math.max(hi - lo, MIN_SPAN);
|
||||
let min = Math.max(0, (lo + hi) / 2 - span / 2);
|
||||
let max = min + span;
|
||||
if (id !== "debtRatio" && max > 100) {
|
||||
max = 100;
|
||||
min = 100 - span;
|
||||
}
|
||||
return `
|
||||
<figure class="end__chart">
|
||||
<figcaption><span>${t(label)}</span><b>${esc(fmt(value, 0))}</b><small>${tag}</small></figcaption>
|
||||
${lineChart(series, { min, max })}
|
||||
</figure>`;
|
||||
}
|
||||
|
||||
function decision(d) {
|
||||
const card = cardsById.get(d.id);
|
||||
const date = dateParts(dateOfDay(root.dataset.startDate, d.day).iso);
|
||||
return `
|
||||
<li>
|
||||
<span class="end__when">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
|
||||
<b>${esc(tx(card.title))}</b>
|
||||
<span class="end__chose">${esc(t("end.chose", { label: tx(card[d.choice].label) }))}</span>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function update(state, bal) {
|
||||
if (!state.over) {
|
||||
if (shown) {
|
||||
root.hidden = true;
|
||||
root.innerHTML = "";
|
||||
shown = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const key = `${state.over.type}:${state.over.day}:${getLang()}`;
|
||||
if (key === shown) return;
|
||||
shown = key;
|
||||
root.dataset.startDate = state.startDate;
|
||||
|
||||
const review = buildReview(state, bal, cards, endgame);
|
||||
const banner = endgame.over[state.over.type];
|
||||
const entry = endgame.titles.find((x) => x.id === review.titleId);
|
||||
const date = dateParts(dateOfDay(state.startDate, state.over.day).iso);
|
||||
const lost = state.over.type !== "term";
|
||||
|
||||
root.innerHTML = `
|
||||
<article class="paper paper--news end ${lost ? "end--lost" : ""}">
|
||||
<header class="news__mast">
|
||||
<h2>${esc(tx(newsName))}</h2>
|
||||
<div class="news__edition">${esc(t("news.edition", { date: date.full }))}</div>
|
||||
</header>
|
||||
<div class="end__banner">
|
||||
<h1>${esc(tx(banner.h))}</h1>
|
||||
<p>${esc(tx(banner.d))}</p>
|
||||
</div>
|
||||
<div class="end__grid">
|
||||
<section class="end__verdict">
|
||||
<h3>${t("end.verdict")}</h3>
|
||||
<div class="end__grade" data-grade="${esc(review.grade)}">
|
||||
<span class="end__letter">${esc(review.grade)}</span>
|
||||
<span>${t("end.grade")}</span>
|
||||
</div>
|
||||
<p class="end__title">${entry ? esc(tt(entry.title, state.pronouns)) : ""}</p>
|
||||
<p class="end__text">${entry ? esc(tp(tx(entry.verdict), state.pronouns)) : ""}</p>
|
||||
<p class="end__score">${t("end.score", { n: Math.round(review.score) })}<br>${t("end.served", { days: state.over.day })}</p>
|
||||
</section>
|
||||
<section class="end__charts">${CHARTS.map((c) => chart(state, c, review)).join("")}</section>
|
||||
<section class="end__decisions">
|
||||
<h3>${t("end.decisions")}</h3>
|
||||
${review.topDecisions.length
|
||||
? `<ol>${review.topDecisions.map(decision).join("")}</ol>`
|
||||
: `<p class="inbox__idle">${t("end.noDecisions")}</p>`}
|
||||
</section>
|
||||
</div>
|
||||
<footer class="end__foot"><button class="brass brass--wide" data-again>${t("end.again")}</button></footer>
|
||||
</article>`;
|
||||
root.hidden = false;
|
||||
$(root, "button[data-again]").focus();
|
||||
}
|
||||
|
||||
return { update };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Quarterly budget ledger: a two-page accounting book. The left page sets sector
|
||||
// budgets, the right page sets tax rates and shows the Treasury summary and a
|
||||
// four-quarter projection. Nothing is applied until the player signs.
|
||||
|
||||
import { t, fmt, getLang } from "../i18n/i18n.js";
|
||||
import {
|
||||
previewBudget, setBudget, confirmBudget, qualityFromRatio, taxRevenue, TAX_IDS,
|
||||
} from "../engine/index.js";
|
||||
import { $ } from "./desk.js";
|
||||
|
||||
const ALLOC_STEP = 0.05;
|
||||
const TAX_STEP = 0.5;
|
||||
|
||||
// Projection rows: label key, field on the summary, digits, suffix
|
||||
const PROJ_ROWS = [
|
||||
["econ.debt", (p) => p.debtRatio, 1, ""],
|
||||
["econ.deficit", (p) => p.deficit, 1, ""],
|
||||
["econ.growth", (p) => p.growth, 1, ""],
|
||||
["econ.unemployment", (p) => p.unemployment, 1, ""],
|
||||
["ind.opinion", (p) => p.indicators.opinion, 0, ""],
|
||||
["ind.stability", (p) => p.indicators.stability, 0, ""],
|
||||
["ind.economy", (p) => p.indicators.economy, 0, ""],
|
||||
];
|
||||
|
||||
export function createLedger(root, scenario, { onConfirm }) {
|
||||
let openKey = null;
|
||||
let ctx = null; // { state, bal }
|
||||
let draft = null;
|
||||
let base = null;
|
||||
let queued = false;
|
||||
|
||||
const sectorIds = (state) => Object.keys(state.sectors);
|
||||
|
||||
function open(state, bal) {
|
||||
ctx = { state, bal };
|
||||
base = {
|
||||
alloc: Object.fromEntries(Object.entries(state.sectors).map(([id, s]) => [id, s.alloc])),
|
||||
taxes: { ...state.economy.taxes },
|
||||
};
|
||||
draft = structuredClone(base);
|
||||
|
||||
const allocMax = Math.min(bal.limits.allocMaxRatio, bal.limits.sliderMaxRatio);
|
||||
const sectorRows = sectorIds(state).map((id) => {
|
||||
const s = state.sectors[id];
|
||||
const max = Math.max(s.need * allocMax, s.alloc);
|
||||
return `
|
||||
<div class="lrow" data-sector="${id}">
|
||||
<label class="lrow__name" for="alloc-${id}" title="${t("sector." + id)}">${t("sector." + id)}</label>
|
||||
<span class="lrow__ctl">
|
||||
<input type="range" id="alloc-${id}" min="0" max="${max}" step="${ALLOC_STEP}" value="${s.alloc}">
|
||||
<i class="lrow__tick" style="left:${(s.need / max) * 100}%"></i>
|
||||
</span>
|
||||
<output class="lrow__val"></output>
|
||||
<span class="lrow__q"></span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const taxRows = TAX_IDS.map((id) => {
|
||||
const max = bal.tax[id].peak * bal.limits.taxMaxFactorOfPeak;
|
||||
return `
|
||||
<div class="lrow lrow--tax" data-tax="${id}">
|
||||
<label class="lrow__name" for="tax-${id}">${t("tax." + id)}</label>
|
||||
<span class="lrow__ctl">
|
||||
<input type="range" id="tax-${id}" min="0" max="${max}" step="${TAX_STEP}" value="${state.economy.taxes[id]}">
|
||||
<i class="lrow__tick" style="left:${(bal.tax[id].peak / max) * 100}%"></i>
|
||||
</span>
|
||||
<output class="lrow__val"></output>
|
||||
<span class="lrow__yield"></span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="ledger__book">
|
||||
<article class="ledger__page ledger__page--left">
|
||||
<h2>${t("ledger.title")}</h2>
|
||||
<p class="ledger__sub">${t("ledger.spending")}. ${t("ledger.spendingHint")}</p>
|
||||
<div class="ledger__rows">${sectorRows}</div>
|
||||
<footer class="ledger__total">${t("ledger.totalAlloc")} <b data-total></b></footer>
|
||||
</article>
|
||||
<article class="ledger__page ledger__page--right">
|
||||
<h2>${t("ledger.revenue")}</h2>
|
||||
<p class="ledger__sub">${t("ledger.revenueHint")}</p>
|
||||
<div class="ledger__rows">${taxRows}</div>
|
||||
<h3>${t("ledger.summary")}</h3>
|
||||
<dl class="sum" data-summary></dl>
|
||||
<h3>${t("ledger.projection")}</h3>
|
||||
<table class="proj"><thead></thead><tbody></tbody></table>
|
||||
<footer class="ledger__actions">
|
||||
<button class="brass" data-act="reset">${t("ledger.reset")}</button>
|
||||
<button class="brass brass--wide" data-act="confirm">${t("ledger.confirm")}</button>
|
||||
</footer>
|
||||
<p class="ledger__error" data-error hidden>${t("ledger.error")}</p>
|
||||
</article>
|
||||
</div>`;
|
||||
root.hidden = false;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function close() {
|
||||
root.hidden = true;
|
||||
root.innerHTML = "";
|
||||
openKey = null;
|
||||
ctx = null;
|
||||
}
|
||||
|
||||
// Cheap parts (values next to sliders) run immediately; the preview is coalesced per frame.
|
||||
function refresh() {
|
||||
const { state, bal } = ctx;
|
||||
let total = 0;
|
||||
for (const id of sectorIds(state)) {
|
||||
const s = state.sectors[id];
|
||||
const v = draft.alloc[id];
|
||||
total += v;
|
||||
const row = $(root, `[data-sector="${id}"]`);
|
||||
$(row, ".lrow__val").textContent = fmt(v, 2);
|
||||
const target = qualityFromRatio(v / s.need, bal);
|
||||
const diff = target - s.quality;
|
||||
const q = $(row, ".lrow__q");
|
||||
q.textContent = diff > 1 ? "▲" : diff < -1 ? "▼" : "=";
|
||||
q.className = `lrow__q ${diff > 1 ? "up" : diff < -1 ? "down" : "flat"}`;
|
||||
q.title = t("ledger.quality", { now: Math.round(s.quality), target: Math.round(target) });
|
||||
row.classList.toggle("is-changed", Math.abs(v - base.alloc[id]) > 1e-9);
|
||||
}
|
||||
$(root, "[data-total]").textContent = `${fmt(total, 2)} %`;
|
||||
|
||||
for (const id of TAX_IDS) {
|
||||
const row = $(root, `[data-tax="${id}"]`);
|
||||
$(row, ".lrow__val").textContent = `${fmt(draft.taxes[id], 1)} %`;
|
||||
$(row, ".lrow__yield").textContent = t("ledger.yield", { n: fmt(taxRevenue(id, draft.taxes[id], bal), 1) });
|
||||
row.classList.toggle("is-changed", Math.abs(draft.taxes[id] - base.taxes[id]) > 1e-9);
|
||||
}
|
||||
|
||||
if (!queued) {
|
||||
queued = true;
|
||||
requestAnimationFrame(() => {
|
||||
queued = false;
|
||||
if (ctx) renderPreview();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
const { state, bal } = ctx;
|
||||
const pv = previewBudget(state, bal, draft);
|
||||
if (!pv.ok) return;
|
||||
const f = pv.fiscal;
|
||||
const gdp = state.economy.gdp;
|
||||
const bn = (pct) => fmt(Math.abs((pct / 100) * gdp), 1);
|
||||
const balance = -f.deficit; // positive = surplus
|
||||
|
||||
$(root, "[data-summary]").innerHTML = `
|
||||
<dt>${t("ledger.sumRevenue")}</dt><dd>${fmt(f.revenue, 1)} %</dd>
|
||||
<dt>${t("ledger.sumSectors")}</dt><dd>${fmt(f.allocTotal, 1)} %</dd>
|
||||
<dt>${t("ledger.sumInterest")}</dt><dd>${fmt(f.interest, 1)} %</dd>
|
||||
<dt>${t("ledger.sumSpending")}</dt><dd>${fmt(f.spending, 1)} %</dd>
|
||||
<dt class="sum__balance ${balance >= 0 ? "good" : "bad"}">${t(balance >= 0 ? "ledger.sumSurplus" : "ledger.sumDeficit")}</dt>
|
||||
<dd class="sum__balance ${balance >= 0 ? "good" : "bad"}">${fmt(Math.abs(balance), 1)} %
|
||||
<small>${t("ledger.perYear", { n: bn(balance), currency: scenario.currency })}</small></dd>`;
|
||||
|
||||
const months = (i) => (i === 0 ? t("ledger.now") : t("ledger.months", { n: i * 3 }));
|
||||
$(root, ".proj thead").innerHTML = `<tr><th></th>${pv.points.map((_, i) => `<th>${months(i)}</th>`).join("")}</tr>`;
|
||||
$(root, ".proj tbody").innerHTML = PROJ_ROWS.map(([label, get, digits, suffix]) =>
|
||||
`<tr><th>${t(label)}</th>${pv.points.map((p) => `<td>${fmt(get(p), digits)}${suffix}</td>`).join("")}</tr>`).join("");
|
||||
}
|
||||
|
||||
function syncSliders() {
|
||||
for (const id of sectorIds(ctx.state)) $(root, `#alloc-${id}`).value = draft.alloc[id];
|
||||
for (const id of TAX_IDS) $(root, `#tax-${id}`).value = draft.taxes[id];
|
||||
}
|
||||
|
||||
root.addEventListener("input", (e) => {
|
||||
const input = e.target;
|
||||
if (!ctx || input.type !== "range") return;
|
||||
const v = Number(input.value);
|
||||
if (input.id.startsWith("alloc-")) draft.alloc[input.id.slice(6)] = v;
|
||||
else if (input.id.startsWith("tax-")) draft.taxes[input.id.slice(4)] = v;
|
||||
refresh();
|
||||
});
|
||||
|
||||
root.addEventListener("click", (e) => {
|
||||
const b = e.target.closest("button[data-act]");
|
||||
if (!b || !ctx) return;
|
||||
if (b.dataset.act === "reset") {
|
||||
draft = structuredClone(base);
|
||||
syncSliders();
|
||||
refresh();
|
||||
} else {
|
||||
const { state, bal } = ctx;
|
||||
const res = setBudget(state, bal, draft);
|
||||
if (!res.ok) {
|
||||
$(root, "[data-error]").hidden = false;
|
||||
return;
|
||||
}
|
||||
confirmBudget(state);
|
||||
close();
|
||||
onConfirm();
|
||||
}
|
||||
});
|
||||
|
||||
function update(state, bal) {
|
||||
if (!(state.pending.review && !state.pending.profile && !state.over)) {
|
||||
if (openKey !== null) close();
|
||||
return;
|
||||
}
|
||||
const key = `${state.day}:${getLang()}`;
|
||||
if (key !== openKey) {
|
||||
openKey = key;
|
||||
open(state, bal);
|
||||
}
|
||||
}
|
||||
|
||||
return { update };
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// Main menu and in-game pause menu: a "Top secret" folder with one screen at a time.
|
||||
// Modes: "main" (before a game) and "game" (pause menu while a game is running).
|
||||
// The menu never changes the game itself; it hands results to main.js through `handlers`.
|
||||
|
||||
import { t, tx, getLang, dateParts } from "../i18n/i18n.js";
|
||||
import { dateOfDay, restoreState, PRONOUNS } from "../engine/index.js";
|
||||
import { encodeSave, decodeSave, migrate } from "../save/codec.js";
|
||||
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
|
||||
import { $, esc } from "./desk.js";
|
||||
|
||||
const ARM_MS = 4000; // how long a "Sure?" button stays armed
|
||||
|
||||
export function createMenu(root, { content, storage, getState, getSettings, handlers }) {
|
||||
let mode = "main";
|
||||
let screen = "home";
|
||||
let message = null; // { text, kind: "ok" | "error" }
|
||||
let armed = null; // "action:arg" waiting for a second click
|
||||
let armTimer = 0;
|
||||
let exportText = "";
|
||||
|
||||
const say = (text, kind = "ok") => { message = { text, kind }; };
|
||||
const go = (next) => { screen = next; message = null; disarm(); render(); };
|
||||
|
||||
function disarm() {
|
||||
armed = null;
|
||||
clearTimeout(armTimer);
|
||||
}
|
||||
|
||||
function arm(key) {
|
||||
armed = key;
|
||||
clearTimeout(armTimer);
|
||||
armTimer = setTimeout(() => { armed = null; render(); }, ARM_MS);
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- Reading saves ----
|
||||
|
||||
const autosave = () => readSave(storage, KEYS.auto);
|
||||
|
||||
function describe(entry) {
|
||||
const scenario = content.scenarios[entry.meta.scenarioId];
|
||||
let date = "";
|
||||
if (scenario) {
|
||||
const p = dateParts(dateOfDay(scenario.startDate, entry.meta.day).iso);
|
||||
date = ` · ${p.day} ${p.month} ${p.year}`;
|
||||
}
|
||||
const saved = new Intl.DateTimeFormat(getLang(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(entry.meta.savedAt));
|
||||
const info = t("load.info", { country: entry.meta.countryName, day: entry.meta.day, date });
|
||||
return `${esc(info)}${entry.meta.over ? ` · ${t("load.finished")}` : ""}<small>${esc(saved)}</small>`;
|
||||
}
|
||||
|
||||
// Turns a stored or imported state into a playable one, or explains why not.
|
||||
function restore(rawState) {
|
||||
const migrated = migrate(rawState);
|
||||
if (!migrated.ok) return migrated;
|
||||
return restoreState(migrated.state, content);
|
||||
}
|
||||
|
||||
function loadEntry(entry) {
|
||||
const r = entry ? restore(entry.state) : { ok: false, error: "invalid" };
|
||||
if (!r.ok) {
|
||||
say(t("load.unreadable"), "error");
|
||||
render();
|
||||
return;
|
||||
}
|
||||
handlers.onLoadState(r.state);
|
||||
}
|
||||
|
||||
// ---- Screens ----
|
||||
|
||||
const button = (action, label, extra = "", cls = "") =>
|
||||
`<button class="brass brass--wide ${cls}" data-action="${action}" ${extra}>${label}</button>`;
|
||||
const back = () => `<footer class="menu__foot">${button("back", t("menu.back"))}</footer>`;
|
||||
const notice = () => (message ? `<p class="menu__msg menu__msg--${message.kind}" role="status">${esc(message.text)}</p>` : "");
|
||||
|
||||
function home() {
|
||||
const state = getState();
|
||||
if (mode === "game") {
|
||||
return `
|
||||
<h3>${t("menu.paused")}</h3>
|
||||
<p class="menu__sub">${esc(t("menu.info", { country: state.countryName, day: state.day }))}</p>
|
||||
<div class="menu__list">
|
||||
${button("resume", t("menu.resume"))}
|
||||
${button("save", t("menu.save"))}
|
||||
${button("load", t("menu.load"))}
|
||||
${button("export", t("menu.export"))}
|
||||
${button("import", t("menu.import"))}
|
||||
${button("settings", t("menu.settings"))}
|
||||
${button("quit", t("menu.quit"))}
|
||||
</div>`;
|
||||
}
|
||||
const auto = autosave();
|
||||
const canContinue = auto && !auto.meta.over;
|
||||
return `
|
||||
<h1 class="menu__title">${t("app.title")}</h1>
|
||||
<p class="menu__sub">${t("menu.tagline")}</p>
|
||||
${notice()}
|
||||
<div class="menu__list">
|
||||
${canContinue ? button("continue", `${t("menu.continue")}<small>${esc(t("menu.info", { country: auto.meta.countryName, day: auto.meta.day }))}</small>`) : ""}
|
||||
${button("new", t("menu.new"))}
|
||||
${button("load", t("menu.load"))}
|
||||
${button("import", t("menu.import"))}
|
||||
${button("settings", t("menu.settings"))}
|
||||
${button("credits", t("menu.credits"))}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function newGame() {
|
||||
const ids = Object.keys(content.scenarios);
|
||||
const first = content.scenarios[ids[0]];
|
||||
return `
|
||||
<h3>${t("new.title")}</h3>
|
||||
<label class="menu__field">${t("new.name")}
|
||||
<input id="new-name" type="text" maxlength="40" placeholder="${esc(tx(first.name))}" autocomplete="off">
|
||||
</label>
|
||||
${ids.length > 1
|
||||
? `<label class="menu__field">${t("new.scenario")}
|
||||
<select id="new-scenario">${ids.map((id) => `<option value="${esc(id)}">${esc(tx(content.scenarios[id].name))}</option>`).join("")}</select>
|
||||
</label>`
|
||||
: `<input id="new-scenario" type="hidden" value="${esc(ids[0])}">`}
|
||||
<fieldset class="menu__field menu__radios">
|
||||
<legend>${t("new.pronouns")}</legend>
|
||||
${PRONOUNS.map((p) => `<label><input type="radio" name="pronouns" value="${p}" ${p === "they" ? "checked" : ""}> ${t("profile." + p)}</label>`).join("")}
|
||||
</fieldset>
|
||||
<div class="menu__list">${button("start", t("new.start"))}</div>
|
||||
${back()}`;
|
||||
}
|
||||
|
||||
function slotRow(label, entry, actions) {
|
||||
return `
|
||||
<li class="menu__slot">
|
||||
<span class="menu__slotname">${label}</span>
|
||||
<span class="menu__slotinfo">${entry ? describe(entry) : `<em>${t("load.empty")}</em>`}</span>
|
||||
<span class="menu__slotbtns">${actions}</span>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
const confirmable = (action, arg, label, enabled = true) => {
|
||||
const isArmed = armed === `${action}:${arg}`;
|
||||
return `<button class="brass ${isArmed ? "brass--armed" : ""}" data-action="${action}" data-arg="${arg}" ${enabled ? "" : "disabled"}>${isArmed ? t("load.sure") : label}</button>`;
|
||||
};
|
||||
|
||||
function load() {
|
||||
const rows = [];
|
||||
const auto = autosave();
|
||||
rows.push(slotRow(t("load.auto"), auto, auto ? confirmable("loadEntry", "auto", t("load.load")) : ""));
|
||||
for (let n = 1; n <= SLOT_COUNT; n++) {
|
||||
const entry = readSave(storage, KEYS.slot(n));
|
||||
rows.push(slotRow(t("load.slot", { n }), entry,
|
||||
entry ? `${confirmable("loadEntry", String(n), t("load.load"))}${confirmable("deleteSlot", String(n), t("load.delete"))}` : ""));
|
||||
}
|
||||
return `
|
||||
<h3>${t("load.title")}</h3>
|
||||
${mode === "game" ? `<p class="menu__sub">${t("load.replace")}</p>` : ""}
|
||||
${notice()}
|
||||
<ul class="menu__slots">${rows.join("")}</ul>
|
||||
${back()}`;
|
||||
}
|
||||
|
||||
function save() {
|
||||
const rows = [];
|
||||
for (let n = 1; n <= SLOT_COUNT; n++) {
|
||||
const entry = readSave(storage, KEYS.slot(n));
|
||||
rows.push(slotRow(t("load.slot", { n }), entry,
|
||||
confirmable("saveSlot", String(n), entry ? t("save.overwrite") : t("save.here"))));
|
||||
}
|
||||
return `
|
||||
<h3>${t("save.title")}</h3>
|
||||
${notice()}
|
||||
<ul class="menu__slots">${rows.join("")}</ul>
|
||||
${back()}`;
|
||||
}
|
||||
|
||||
function exportScreen() {
|
||||
return `
|
||||
<h3>${t("export.title")}</h3>
|
||||
<p class="menu__sub">${t("export.hint")}</p>
|
||||
<textarea class="menu__text" id="export-text" readonly rows="8" spellcheck="false">${esc(exportText)}</textarea>
|
||||
${notice()}
|
||||
<div class="menu__row">
|
||||
${button("copy", t("export.copy"))}
|
||||
${button("download", t("export.download"))}
|
||||
</div>
|
||||
${back()}`;
|
||||
}
|
||||
|
||||
function importScreen() {
|
||||
return `
|
||||
<h3>${t("import.title")}</h3>
|
||||
<p class="menu__sub">${t("import.hint")}${mode === "game" ? ` ${t("load.replace")}` : ""}</p>
|
||||
<textarea class="menu__text" id="import-text" rows="8" spellcheck="false" placeholder="CG1:…"></textarea>
|
||||
${notice()}
|
||||
<div class="menu__row">
|
||||
<label class="brass brass--wide menu__file">${t("import.file")}<input type="file" id="import-file" accept=".sav,.txt,text/plain" hidden></label>
|
||||
${button("doImport", t("import.go"))}
|
||||
</div>
|
||||
${back()}`;
|
||||
}
|
||||
|
||||
function settings() {
|
||||
const s = getSettings();
|
||||
const lang = getLang();
|
||||
return `
|
||||
<h3>${t("settings.title")}</h3>
|
||||
<div class="menu__field">
|
||||
<span>${t("settings.language")}</span>
|
||||
<div class="menu__row">
|
||||
<button class="brass" data-action="lang" data-arg="en" aria-pressed="${lang === "en"}">English</button>
|
||||
<button class="brass" data-action="lang" data-arg="fr" aria-pressed="${lang === "fr"}">Français</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="menu__field menu__check">
|
||||
<input type="checkbox" id="setting-slow" ${s.slow ? "checked" : ""}>
|
||||
<span><b>${t("settings.slow")}</b><small>${t("settings.slowHint")}</small></span>
|
||||
</label>
|
||||
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
|
||||
${back()}`;
|
||||
}
|
||||
|
||||
function credits() {
|
||||
return `
|
||||
<h3>${t("credits.title")}</h3>
|
||||
<p>${t("credits.fiction")}</p>
|
||||
<p>${t("credits.tech")}</p>
|
||||
<p>${t("credits.fonts")}</p>
|
||||
${back()}`;
|
||||
}
|
||||
|
||||
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits };
|
||||
|
||||
function render() {
|
||||
root.innerHTML = `
|
||||
<div class="menu__folder">
|
||||
<h2 class="inbox__tab">${t("menu.secret")}</h2>
|
||||
<span class="stamp menu__stamp" aria-hidden="true">${t("menu.secret")}</span>
|
||||
<div class="menu__sheet">${SCREENS[screen]()}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- Actions ----
|
||||
|
||||
async function copyExport() {
|
||||
const box = $(root, "#export-text");
|
||||
try {
|
||||
await navigator.clipboard.writeText(exportText);
|
||||
say(t("export.copied"));
|
||||
} catch {
|
||||
box.select();
|
||||
say(document.execCommand?.("copy") ? t("export.copied") : t("export.copyFailed"), "ok");
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function download() {
|
||||
const state = getState();
|
||||
const slug = state.countryName.toLowerCase().normalize("NFD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "game";
|
||||
const url = URL.createObjectURL(new Blob([exportText], { type: "text/plain" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${slug}-day${state.day}.sav`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function doImport() {
|
||||
const text = $(root, "#import-text").value;
|
||||
if (!text.trim()) {
|
||||
say(t("import.error.empty"), "error");
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const decoded = decodeSave(text);
|
||||
const r = decoded.ok ? restoreState(decoded.state, content) : decoded;
|
||||
if (!r.ok) {
|
||||
say(t("import.error." + r.error), "error");
|
||||
const keep = text;
|
||||
render();
|
||||
$(root, "#import-text").value = keep;
|
||||
return;
|
||||
}
|
||||
handlers.onLoadState(r.state);
|
||||
}
|
||||
|
||||
function start() {
|
||||
const id = $(root, "#new-scenario").value;
|
||||
const pronouns = root.querySelector('input[name="pronouns"]:checked')?.value ?? "they";
|
||||
const name = $(root, "#new-name").value.trim();
|
||||
handlers.onStart({ countryName: name || tx(content.scenarios[id].name), scenarioId: id, pronouns });
|
||||
}
|
||||
|
||||
const ACTIONS = {
|
||||
continue: () => loadEntry(autosave()),
|
||||
new: () => go("new"),
|
||||
load: () => go("load"),
|
||||
save: () => go("save"),
|
||||
export: () => { exportText = encodeSave(getState()); go("export"); },
|
||||
import: () => go("import"),
|
||||
settings: () => go("settings"),
|
||||
credits: () => go("credits"),
|
||||
back: () => go("home"),
|
||||
resume: () => handlers.onResume(),
|
||||
quit: () => handlers.onQuit(),
|
||||
start,
|
||||
copy: copyExport,
|
||||
download,
|
||||
doImport,
|
||||
lang: (arg) => { handlers.onSettings({ lang: arg }); render(); },
|
||||
loadEntry: (arg) => loadEntry(readSave(storage, arg === "auto" ? KEYS.auto : KEYS.slot(arg))),
|
||||
deleteSlot: (arg) => { storage.remove(KEYS.slot(arg)); disarm(); render(); },
|
||||
saveSlot: (arg) => {
|
||||
const ok = writeSave(storage, KEYS.slot(arg), getState());
|
||||
disarm();
|
||||
say(ok ? t("save.done", { n: arg }) : t("save.failed"), ok ? "ok" : "error");
|
||||
render();
|
||||
},
|
||||
};
|
||||
// Destructive actions need a second click on the same button.
|
||||
const NEEDS_CONFIRM = {
|
||||
loadEntry: () => mode === "game",
|
||||
deleteSlot: () => true,
|
||||
saveSlot: (arg) => readSave(storage, KEYS.slot(arg)) !== null,
|
||||
};
|
||||
|
||||
root.addEventListener("click", (e) => {
|
||||
const el = e.target.closest("[data-action]");
|
||||
if (!el || el.disabled) return;
|
||||
const { action, arg } = el.dataset;
|
||||
const key = `${action}:${arg}`;
|
||||
if (NEEDS_CONFIRM[action]?.(arg) && armed !== key) {
|
||||
arm(key);
|
||||
return;
|
||||
}
|
||||
disarm();
|
||||
ACTIONS[action](arg);
|
||||
});
|
||||
|
||||
root.addEventListener("change", async (e) => {
|
||||
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
|
||||
if (e.target.id === "import-file" && e.target.files[0]) {
|
||||
$(root, "#import-text").value = await e.target.files[0].text();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (root.hidden || e.key !== "Escape") return;
|
||||
if (screen !== "home") go("home");
|
||||
else if (mode === "game") handlers.onResume();
|
||||
});
|
||||
|
||||
return {
|
||||
show(nextMode, nextScreen = "home") {
|
||||
mode = nextMode;
|
||||
screen = nextScreen;
|
||||
message = null;
|
||||
disarm();
|
||||
render();
|
||||
root.hidden = false;
|
||||
},
|
||||
hide() {
|
||||
root.hidden = true;
|
||||
root.innerHTML = "";
|
||||
disarm();
|
||||
},
|
||||
// Re-render after the language changed.
|
||||
refresh() {
|
||||
if (!root.hidden) render();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Daily newspaper: headlines picked from templates by how much the indicators
|
||||
// moved over the last few weeks. Selection is deterministic (hash of week + key)
|
||||
// and never touches the game RNG, so reading the paper can't change the game.
|
||||
|
||||
import { t, tx, getLang, dateParts } from "../i18n/i18n.js";
|
||||
import { IND_IDS, dateOfDay } from "../engine/index.js";
|
||||
import { hash, $ } from "./desk.js";
|
||||
|
||||
const IND_THRESHOLD = 4; // points moved over the window to make news
|
||||
const VAR_THRESHOLD = { growth: 0.4, unemployment: 0.4, inflation: 0.5, debtRatio: 3 };
|
||||
|
||||
// Each entry: [template key, test on the live state, severity score]
|
||||
const LEVELS = [
|
||||
["unrest.high", (s, f) => s.indicators.unrest >= 50, 1.7],
|
||||
["opinion.low", (s, f) => s.indicators.opinion <= 30, 1.6],
|
||||
["debt.high", (s, f) => f.debtRatio >= 100, 1.6],
|
||||
["stability.low", (s, f) => s.indicators.stability <= 35, 1.6],
|
||||
["civilRights.low", (s, f) => s.indicators.civilRights <= 25, 1.5],
|
||||
["army.low", (s, f) => s.indicators.armyLoyalty <= 35, 1.7],
|
||||
["inflation.high", (s, f) => s.economy.inflation >= 6, 1.5],
|
||||
["deficit.high", (s, f) => f.deficit >= 8, 1.5],
|
||||
];
|
||||
|
||||
function candidates(state, tpl) {
|
||||
const out = [];
|
||||
const h = state.history;
|
||||
const n = h.t.length;
|
||||
if (n >= 2) {
|
||||
const back = Math.min(4, n - 1);
|
||||
const consider = (group, id, threshold) => {
|
||||
const s = h.series[id];
|
||||
const d = s[n - 1] - s[n - 1 - back];
|
||||
const dir = d > 0 ? "up" : "down";
|
||||
const tpls = tpl[group][id]?.[dir];
|
||||
if (tpls && Math.abs(d) / threshold >= 1) out.push({ score: Math.abs(d) / threshold, key: `${group}.${id}.${dir}`, tpls });
|
||||
};
|
||||
for (const id of IND_IDS) consider("ind", id, IND_THRESHOLD);
|
||||
for (const [id, thr] of Object.entries(VAR_THRESHOLD)) consider("var", id, thr);
|
||||
}
|
||||
const debtRatio = h.series.debtRatio?.[n - 1] ?? 0;
|
||||
const deficit = h.series.deficit?.[n - 1] ?? 0;
|
||||
for (const [key, test, score] of LEVELS) {
|
||||
if (tpl.level[key] && test(state, { debtRatio, deficit })) out.push({ score, key: `level.${key}`, tpls: tpl.level[key] });
|
||||
}
|
||||
return out.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
export function computeHeadlines(state, tpl) {
|
||||
const week = Math.floor(state.day / 7);
|
||||
const pick = (tpls, key) => tpls[hash(`${key}:${week}`) % tpls.length];
|
||||
|
||||
if (state.history.t.length < 2 && state.day === 0) return [pick(tpl.intro, "intro")];
|
||||
const found = candidates(state, tpl);
|
||||
if (!found.length) return [pick(tpl.quiet, "quiet")];
|
||||
return found.slice(0, 3).map((c) => pick(c.tpls, c.key));
|
||||
}
|
||||
|
||||
export function createNewspaper(root, tpl) {
|
||||
root.innerHTML = `
|
||||
<header class="news__mast">
|
||||
<h2>${tx(tpl.name)}</h2>
|
||||
<div class="news__edition"></div>
|
||||
</header>
|
||||
<article class="news__lead"><h3></h3><p></p></article>
|
||||
<div class="news__side"></div>`;
|
||||
|
||||
let shown = "";
|
||||
|
||||
function update(state) {
|
||||
// The date line follows the calendar; headlines only change with the weekly history sample.
|
||||
$(root, ".news__edition").textContent = t("news.edition", { date: dateParts(dateOfDay(state.startDate, state.day).iso).full });
|
||||
|
||||
const week = Math.floor(state.day / 7);
|
||||
const key = `${week}|${getLang()}|${state.history.t.length}`;
|
||||
if (key === shown) return;
|
||||
shown = key;
|
||||
|
||||
const fill = (s) => s.replaceAll("{country}", state.countryName);
|
||||
const [lead, ...rest] = computeHeadlines(state, tpl);
|
||||
$(root, ".news__lead h3").textContent = fill(tx(lead.h));
|
||||
$(root, ".news__lead p").textContent = fill(tx(lead.d));
|
||||
|
||||
const side = $(root, ".news__side");
|
||||
side.replaceChildren(...rest.map((item) => {
|
||||
const a = document.createElement("article");
|
||||
const h = document.createElement("h4");
|
||||
const p = document.createElement("p");
|
||||
h.textContent = fill(tx(item.h));
|
||||
p.textContent = fill(tx(item.d));
|
||||
a.append(h, p);
|
||||
return a;
|
||||
}));
|
||||
}
|
||||
|
||||
return { update };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Urgent telegrams: the game warns that a way of losing power is getting close.
|
||||
// They slide in over the desk and time stays stopped until the player acknowledges.
|
||||
|
||||
import { t, tx } from "../i18n/i18n.js";
|
||||
import { $, esc } from "./desk.js";
|
||||
|
||||
export function createTelegram(root, { alerts, onDismiss }) {
|
||||
root.addEventListener("click", (e) => {
|
||||
if (e.target.closest("button[data-dismiss]")) onDismiss();
|
||||
});
|
||||
|
||||
let shown = "";
|
||||
|
||||
function update(state) {
|
||||
const alert = state.pending.alert && !state.over ? state.alerts[0] : null;
|
||||
const key = alert ? `${alert.kind}:${alert.day}` : "";
|
||||
if (key === shown) return;
|
||||
shown = key;
|
||||
|
||||
if (!alert) {
|
||||
root.hidden = true;
|
||||
root.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const text = alerts[alert.kind];
|
||||
root.innerHTML = `
|
||||
<article class="telegram" role="alertdialog" aria-labelledby="telegram-title">
|
||||
<header class="telegram__head">
|
||||
<span>${t("alert.urgent")}</span>
|
||||
<span>${state.alerts.length > 1 ? `+${state.alerts.length - 1}` : ""}</span>
|
||||
</header>
|
||||
<h3 id="telegram-title">${esc(tx(text.title))}</h3>
|
||||
<p>${esc(tx(text.body))}</p>
|
||||
<button class="brass brass--wide" data-dismiss>${t("alert.ack")}</button>
|
||||
</article>`;
|
||||
root.hidden = false;
|
||||
$(root, "button").focus();
|
||||
}
|
||||
|
||||
return { update };
|
||||
}
|
||||
Reference in New Issue
Block a user