feat: Doing more polishing (M8b)

This commit is contained in:
gauvainboiche
2026-10-25 18:50:17 +01:00
parent c2103c32e7
commit c55e5a2484
34 changed files with 1605 additions and 106 deletions
+83
View File
@@ -0,0 +1,83 @@
/* Motion: papers landing on the desk, stamps, the dial, page turns. All of it is skipped for people
who ask for reduced motion (see the media query at the bottom). */
/* Papers are dealt onto the desk when a game starts */
@keyframes deal {
from { opacity: 0; transform: translateY(-28px) rotate(calc(var(--rot, 0deg) - 3deg)); }
to { opacity: 1; transform: translateY(0) rotate(var(--rot, 0deg)); }
}
#pad.is-dealing #dashboard { animation: deal 0.55s ease-out both; }
#pad.is-dealing #folder { animation: deal 0.55s 0.12s ease-out both; }
#pad.is-dealing #calendar { animation: deal 0.55s 0.24s ease-out both; }
#pad.is-dealing #newspaper { animation: deal 0.55s 0.36s ease-out both; }
/* A proposal arrives in the tray, is stamped, and slides away */
@keyframes card-in {
from { opacity: 0; transform: translateY(22px) rotate(0.8deg); }
to { opacity: 1; transform: none; }
}
@keyframes card-out {
from { opacity: 1; transform: none; }
to { opacity: 0; transform: translateX(70%) rotate(7deg); }
}
@keyframes bump {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
.card--enter { animation: card-in 0.38s cubic-bezier(0.2, 0.8, 0.3, 1) both; }
.card--leave { animation: card-out 0.32s ease-in forwards; }
.inbox__count.is-bump { animation: bump 0.45s ease-out; }
/* The calendar page flips when the day changes; the dial hand turns smoothly (set per frame) */
@keyframes day-flip {
from { opacity: 0.2; transform: perspective(200px) rotateX(-65deg); }
to { opacity: 1; transform: perspective(200px) rotateX(0); }
}
.cal__day.is-flip { animation: day-flip 0.35s ease-out; transform-origin: 50% 0; }
/* Indicator values flash when they move */
@keyframes flash-up { 0% { color: var(--ink-green); } 100% { color: inherit; } }
@keyframes flash-down { 0% { color: var(--ink-red); } 100% { color: inherit; } }
.gauge__value.is-up { animation: flash-up 1.4s ease-out; }
.gauge__value.is-down { animation: flash-down 1.4s ease-out; }
/* Newspaper: fresh headlines fade up */
@keyframes fade-up {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.paper--news.is-fresh .news__lead { animation: fade-up 0.5s ease-out both; }
.paper--news.is-fresh .news__side article { animation: fade-up 0.5s 0.15s ease-out both; }
/* The ledger opens like a book; the menu folder and the end front page rise into place */
@keyframes book-open {
from { opacity: 0; transform: perspective(900px) translateY(24px) rotateX(10deg); }
to { opacity: 1; transform: none; }
}
.ledger__book { animation: book-open 0.45s ease-out both; transform-origin: 50% 100%; }
@keyframes rise {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: none; }
}
.menu__folder { animation: rise 0.4s ease-out both; }
.end__page, #endgame > * { animation: rise 0.6s ease-out both; }
/* Brass buttons and stamp buttons answer the finger */
.brass { transition: transform 0.08s, filter 0.15s, box-shadow 0.15s; }
.stamp-btn { transition: background 0.15s, transform 0.08s; }
.stamp-btn:active:not(:disabled) { transform: translateY(1px) scale(0.99); }
/* The danger "Hold on power" line pulses when it is critical */
@keyframes pulse { 50% { opacity: 0.45; } }
.intel li[data-level="3"] b, .intel li[data-level="4"] b { animation: pulse 1.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
#pad.is-dealing .paper,
.card--enter, .card--leave, .inbox__count.is-bump, .cal__day.is-flip,
.gauge__value.is-up, .gauge__value.is-down,
.paper--news.is-fresh .news__lead, .paper--news.is-fresh .news__side article,
.ledger__book, .menu__folder, .end__page, #endgame > *,
.intel li b { animation: none !important; }
.card--leave { opacity: 0; }
}
+50
View File
@@ -145,3 +145,53 @@
@media (prefers-reduced-motion: reduce) {
.card__stamp { animation: none; opacity: 0.9; }
}
/* ---- The tray: counter on the tab, "full" banner, and the pile of waiting sheets ---- */
.inbox__count {
display: inline-block;
min-width: 2.7em;
margin-left: 6px;
padding: 0 7px;
border-radius: 10px;
background: var(--ink);
color: #f6ead0;
font: 700 12px/18px var(--font-num);
letter-spacing: 0;
text-align: center;
vertical-align: 1px;
}
.inbox__count:empty { display: none; }
.inbox__count.is-warn { background: var(--ink-red); }
.inbox__full {
margin: 0 0 8px;
padding: 6px 10px;
border: 2px solid var(--ink-red);
color: var(--ink-red);
background: rgba(255, 255, 255, 0.35);
font-size: 12px;
letter-spacing: 0.06em;
text-align: center;
text-transform: uppercase;
transform: rotate(-0.8deg);
}
.inbox__full[hidden] { display: none; }
/* Sheets under the top paper: stacked box-shadows that offset a little more for each waiting proposal */
.card[data-pile] { transition: box-shadow 0.35s ease; margin: 0 14px 14px 0; }
.card[data-pile="0"] { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); }
.card[data-pile="1"] {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35),
4px 4px 0 0 #e8dcb8, 4px 4px 0 1px rgba(80, 55, 20, 0.45);
}
.card[data-pile="2"] {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35),
4px 4px 0 0 #e8dcb8, 4px 4px 0 1px rgba(80, 55, 20, 0.45),
8px 8px 0 0 #e0d2ab, 8px 8px 0 1px rgba(80, 55, 20, 0.45);
}
.card[data-pile="3"] {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35),
4px 4px 0 0 #e8dcb8, 4px 4px 0 1px rgba(80, 55, 20, 0.45),
8px 8px 0 0 #e0d2ab, 8px 8px 0 1px rgba(80, 55, 20, 0.45),
12px 12px 0 0 #d8c9a2, 12px 12px 0 1px rgba(80, 55, 20, 0.45);
}
+23
View File
@@ -124,3 +124,26 @@
to { opacity: 1; transform: translateY(0) rotate(-0.4deg); }
}
.menu__stamp { max-width: none; white-space: nowrap; }
/* ---- The rules, rendered from Markdown ---- */
.rules {
max-height: 56vh;
padding: 2px 10px 6px 2px;
overflow-y: auto;
font-family: var(--font-head);
font-size: 15px;
line-height: 1.45;
}
.rules:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.rules h1 { margin: 0 0 8px; font-size: 25px; }
.rules h2 { margin: 18px 0 6px; padding-bottom: 2px; border-bottom: 2px solid var(--ink); font-size: 18px; letter-spacing: 0.06em; text-transform: uppercase; }
.rules h3 { margin: 12px 0 4px; font-size: 16px; font-style: italic; }
.rules p { margin: 0 0 8px; }
.rules ul, .rules ol { margin: 0 0 8px; padding-left: 20px; }
.rules li { margin-bottom: 2px; }
.rules code { font-family: var(--font-num); font-size: 0.9em; background: rgba(28, 42, 68, 0.1); padding: 0 3px; }
.rules table { width: 100%; margin: 6px 0 12px; border-collapse: collapse; font-size: 13px; }
.rules th, .rules td { padding: 3px 6px; border: 1px solid rgba(43, 36, 24, 0.35); text-align: left; vertical-align: top; }
.rules th { background: rgba(28, 42, 68, 0.1); font-weight: 700; }
.rules blockquote { margin: 8px 0; padding: 4px 10px; border-left: 4px solid var(--ink-red); background: rgba(255, 255, 255, 0.3); font-style: italic; }
.rules hr { border: 0; border-top: 1px dashed rgba(43, 36, 24, 0.5); margin: 14px 0; }
+142
View File
@@ -0,0 +1,142 @@
/* Phones and small tablets: one column, one paper at a time, a shelf along the top to pick it.
Loaded last so it can override the desktop layout. */
#shelf { display: none; }
@media (max-width: 900px) {
#desk {
min-height: 100dvh;
padding: 6px;
place-items: stretch;
}
#pad {
width: 100%;
height: calc(100dvh - 12px);
min-height: 0;
padding: 12px 10px 10px;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
border-radius: 12px;
}
#pad::before { inset: 5px; border-radius: 8px; }
#pad > * { grid-column: 1; }
/* Papers lie flat on a phone: a rotated sheet wastes the little width there is. */
.paper { transform: none; }
/* One paper at a time */
#dashboard, #folder, #ledger, #newspaper, #calendar { grid-row: 2; display: none; overflow-y: auto; }
#side { display: contents; }
#pad[data-view="tray"] #folder,
#pad[data-view="brief"] #dashboard,
#pad[data-view="news"] #newspaper,
#pad[data-view="clock"] #calendar { display: block; }
#calendar { align-self: start; }
/* The ledger is a view of its own while a review is open; the tabs still reach the other papers */
#pad.is-ledger #folder { display: none; }
#pad[data-view="ledger"] #ledger:not([hidden]) { display: block; }
#pad.is-ledger #side { display: contents; }
#ledger { grid-column: 1; }
/* Menu and end screens cover the shelf too */
#menu, #endgame { grid-row: 1 / -1; }
/* ---- The shelf ---- */
#shelf {
display: grid;
grid-row: 1;
gap: 6px;
z-index: 5;
padding-top: 2px;
}
.shelf__time {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 5px 8px;
background-color: var(--paper);
background-image: var(--paper-grain);
border-radius: 3px;
box-shadow: inset 0 0 14px rgba(120, 90, 40, 0.22), 0 4px 8px rgba(0, 0, 0, 0.4);
}
.shelf__dial { width: 40px; height: 40px; }
.shelf__date b { display: block; font-family: var(--font-head); font-weight: 400; font-size: 17px; line-height: 1.1; text-transform: capitalize; }
.shelf__date span { font-size: 12px; opacity: 0.7; }
.shelf__speed { min-width: 56px; min-height: 40px; font-size: 16px; }
.shelf__tabs { display: flex; gap: 4px; overflow-x: auto; scrollbar-width: none; }
.shelf__tabs::-webkit-scrollbar { display: none; }
.shelf__tab {
position: relative;
flex: 1 0 auto;
min-height: 44px;
padding: 4px 11px;
font-family: var(--font-type);
font-size: 13px;
color: var(--text);
background: #c6a566;
border: 1px solid rgba(90, 60, 20, 0.55);
border-radius: 6px 6px 0 0;
cursor: pointer;
}
.shelf__tab[aria-pressed="true"] {
background-color: var(--paper);
box-shadow: inset 0 4px 0 var(--ink-red);
}
.shelf__tab--ledger { background: #cfe0c8; }
.shelf__tab--menu { flex: 0 0 auto; background: linear-gradient(#e7c872, #b98e32); font-weight: 700; }
.shelf__tab:focus-visible { outline: 2px solid var(--ink); outline-offset: -3px; }
.shelf__badge {
position: absolute;
top: -5px;
right: -3px;
min-width: 19px;
padding: 0 5px;
border-radius: 10px;
background: var(--ink);
color: #f6ead0;
font: 700 12px/19px var(--font-num);
font-style: normal;
text-align: center;
}
.shelf__badge[hidden] { display: none; }
.shelf__badge.is-warn { background: var(--ink-red); }
/* ---- Papers, sized for a narrow screen ---- */
.paper { padding: 12px 12px; }
.paper--folder { padding-top: 38px; }
.gauge { grid-template-columns: 104px minmax(40px, 1fr) 28px 62px; gap: 6px; }
.gauge__spark { width: 62px; }
.dash__head h2 { font-size: 19px; }
.card { padding: 14px 14px 10px; }
.card__title { font-size: 22px; }
.card__choices { grid-template-columns: 1fr; gap: 8px; }
.stamp-btn { min-height: 62px; flex-direction: row; justify-content: space-between; text-align: left; }
.card__keys { display: none; }
.card__stamp { font-size: 34px; }
.cal__day { font-size: 46px; }
.ledger__book { height: auto; min-height: 100%; grid-template-columns: 1fr; overflow-y: visible; }
.ledger__page { overflow: visible; padding: 12px 12px 12px 24px; }
.ledger__page--left, .ledger__page--right { border-radius: 4px; box-shadow: none; }
.ledger__page--right { margin-top: 8px; }
.menu__folder { width: 100%; padding: 40px 14px 16px; }
.rules { max-height: calc(100dvh - 250px); }
.alerts { top: 8px; width: calc(100% - 16px); }
}
@media (max-width: 420px) {
.gauge { grid-template-columns: 92px minmax(30px, 1fr) 26px; }
.gauge__spark { display: none; }
.gauge__label { font-size: 12px; }
}
/* Landscape phones: the shelf shrinks to one row so the paper keeps its height */
@media (max-width: 900px) and (max-height: 480px) {
#pad { grid-template-rows: auto minmax(0, 1fr); padding: 8px; }
.shelf__time { display: none; }
}
+9
View File
@@ -255,3 +255,12 @@
.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; }
/* Dial clock: one full turn of the hand is one in-game day */
.cal__top { display: flex; align-items: center; justify-content: center; gap: 12px; }
.cal__date { text-align: left; }
.cal__dial { width: 76px; height: 76px; flex: none; }
.dial__face { fill: #f6efd8; stroke: var(--ink); stroke-width: 3; }
.dial__ticks line { stroke: var(--ink); stroke-width: 3; stroke-linecap: round; }
.dial__hand { stroke: var(--ink-red); stroke-width: 4; stroke-linecap: round; }
.dial__pin { fill: var(--ink); }
+4 -2
View File
@@ -7,7 +7,8 @@
"msPerDay": 10000,
"slowMsPerDay": 60000,
"speeds": [1, 2, 4],
"historyEvery": 7
"historyEvery": 7,
"catchUpMs": 60000
},
"limits": { "allocMaxRatio": 3, "sliderMaxRatio": 2, "taxMaxFactorOfPeak": 2 },
"noise": { "indicator": 0.35 },
@@ -51,7 +52,8 @@
"environment": { "sector": "environment" },
"justice": { "sector": "justice" },
"culture": { "sector": "culture" }
}
},
"inboxMax": 10
},
"sector": { "qualityCurve": 0.9, "industryDebtPenaltyFrom": 90, "industryDebtPenaltyPerPoint": 0.25 },
"sectorTau": {
+174
View File
@@ -0,0 +1,174 @@
# The Desk: Rules
You rule the Republic of Valdoria for five years, from a wooden desk. Papers arrive, you sign or refuse them, you set the budget, and the country answers. This sheet explains what is on the desk and how the machinery behind it behaves.
---
## Your task
Rule for the full term, **1826 days**, without being thrown out of office. Every decision carries your signature, and most of them have a price that arrives later.
When the term ends, a final review is drawn up: a grade from **S** to **F**, a title from the historians, and charts of the period. The **last year counts for half of the score**, so a boom bought on credit does not pay. You are judged on the country you leave behind.
> The game never shows you its formulas. Read the papers, watch the gauges, and learn from what happens.
## The desk
Four things sit in front of you. On a phone they share the screen, and a shelf at the top switches between Tray, Briefing, News and Calendar.
### The briefing
The *Ministerial Briefing* is your dashboard. It has three parts.
- Seven **indicators**, each with a gauge and a small sparkline of its recent history.
- The **economic figures**: GDP, growth, unemployment, inflation, debt and deficit as a share of GDP, and the interest rate, each with a trend arrow.
- The **intelligence summary**, which covers what the gauges cannot show you (see below).
### The in-tray
The *In-tray* holds the proposals waiting for your signature. The one on top is the one you answer. When it is empty, the country simply carries on.
### The calendar
The calendar shows the day count, the next briefing and the next budget review. A clock dial turns once per in-game day, and turns faster at higher speeds. The speed buttons, the pause and the Menu are here too.
### The newspaper
The press reports what moved: indicators, growth, jobs, prices, debt, and the state of the world economy. It is often the first place you learn that a slump or a boom has started.
## The seven indicators
Each runs from 0 to 100. They move slowly, they pull on one another, and they all drift downhill if you leave them alone.
| Indicator | What it measures | What drives it |
|---|---|---|
| Order | Calm streets, a working state | Police, justice, administration; falls with unemployment and unrest |
| Stability | Solidity of institutions | Social spending, health, housing, justice; falls with debt, unemployment, inflation |
| Civil rights | Freedom and fair treatment | Justice; falls with heavy policing, a large army and crackdowns |
| Public opinion | Mood of the people | Health, social, housing, jobs, growth; falls with high prices and heavy taxes |
| Economy | Strength of the productive base | Industry, infrastructure, research, education; growth and jobs |
| Hard power | Military weight | Army above all, then research and industry |
| Soft power | Influence abroad | Culture, foreign affairs, civil rights |
### The intelligence summary
Two gauges are hidden from you, and the summary describes them in words. A third line tells you how safe your seat is.
- **Unrest** reads Calm, Restless, Tense, Volatile or Explosive. It climbs with unemployment, inflation and a hostile public.
- **Army loyalty** reads Mutinous, Wavering, Steady or Loyal. It follows the army's budget, but also debt, inflation and the general mood.
- **Hold on power** reads Secure, Shaky or Critical. Shaky means you are close to a danger zone. Critical means you are in one.
## Time
One in-game day lasts **10 seconds** at speed 1. In Settings, *Slow time* stretches a day to 60 seconds.
- **Speeds**: 1x, 2x and 4x, plus pause. Space pauses and resumes; the keys 1, 2 and 3 pick the speed.
- The game keeps running if the browser tab is in the background. The tab title shows how many proposals are waiting for you.
- The game stops by itself for the budget review, for urgent telegrams, and when the in-tray is full (see below).
## Proposals
Every **Monday** a new proposal lands in the in-tray. It comes as a memorandum, a report, a letter, a petition or a telegram, and it names its sender.
Proposals **pile up**. The game does not stop for each one, so time keeps moving while the stack grows. You answer the top one whenever you choose.
### Answering
Stamp it **Approved** or **Rejected**, with the buttons or with the keys **Y** and **N**. Both answers cost something: there is no free choice, and refusing is a decision like any other.
The game **pauses only when 10 proposals are waiting**, not after 10 weeks, and stays paused until you answer one.
### Consequences
Answers have lasting effects, and many arrive late. Some set off a follow-up proposal weeks later: a promise comes due, a refusal turns into a petition, a gamble goes wrong.
A run draws roughly 260 proposals from a pool of more than 900, so no two terms read the same. Crisis telegrams are different: they react to the state of the country and to extreme choices. Starve the army, squeeze the taxpayers or arm the police to the teeth, and the telegrams will notice.
## The budget
On day 0 and then **every quarter (13 weeks)**, the *Treasury ledger* opens and the game pauses. Between reviews the ledger is closed: the budget you filed is the budget you live with. Nothing takes effect until you press *Sign and file*, and *Reset* returns to the figures the ledger opened with.
### Sectors
Fifteen sectors share the spending: education, health, social and pensions, police, army, justice, industry and energy, public transport, infrastructure, housing, research, culture and media, foreign affairs, environment, administration.
Each is set as a **share of GDP**. A tick on each line marks what the sector **needs**, and needs grow a little every year, so a frozen budget slowly buys less.
Each sector has a **quality** from 0 to 100 that follows the budget slowly. Police reacts within weeks, health within months, education and infrastructure only over years. Above about **70**, a sector brings extra benefits; below about **40**, it starts to hurt. Overspending gives diminishing returns, so the last coins buy the least.
### Taxes
Three taxes can be set: **income tax**, **corporate tax** and **VAT**. For each, revenue rises with the rate up to a peak, marked by a tick, and then **falls**. Beyond the peak you collect less and still pay in opinion and growth. Each tax hurts in its own way: VAT is the most felt by the public, corporate tax weighs most on growth.
### The projection
The ledger shows a Treasury summary (revenue, sector spending, debt interest, surplus or deficit) and a **four-quarter projection** of the deficit and debt. It assumes nothing else changes, so it cannot foresee the world cycle or the proposals to come.
## Debt
Each day the deficit is added to the debt, and a surplus pays it down. Debt is measured against GDP.
- **Below 60%**, debt is free: it costs nothing extra to carry.
- **Above 60%**, an interest premium appears and climbs steeply. From about **100%** it is dangerous, and bond panics and rating downgrades can arrive as proposals.
- At **135%**, a warning telegram arrives.
- **Above 165%**, the country defaults and your term ends at once.
Interest eats into the money you would rather spend on sectors, so debt feeds on itself.
## Ways to lose power
Four things end the game at once, each with its own front page. The chance grows with every day spent in the danger zone, so leaving it quickly matters more than leaving it perfectly.
| Ending | Trigger | Warning |
|---|---|---|
| Revolution | Unrest above 62 and public opinion below 28 | Urgent telegram: crowds at the gates |
| Military coup | Army loyalty below 35 with stability below 50, or army loyalty below 18 alone | Urgent telegram: the generals are restless |
| Impeachment | Civil rights below 10 and public opinion below 25, for 60 days in a row | Urgent telegram: a motion of no confidence |
| Default | Debt above 165% of GDP | Urgent telegram at 135%: lenders pull back |
### Warnings
Entering a danger zone sends an **urgent telegram**, and the game pauses until you acknowledge it. An **Emergency session** then opens the ledger at once, outside the quarterly schedule, so you can change course without waiting. It happens at most once every 45 days. A revolution or a coup is not certain on the first day, but it only gets likelier.
## The world
Outside your borders, a **global business cycle** swings between slumps and booms. It moves growth, inflation and revenue whatever you do, and a slump can turn a balanced budget into a deficit. The newspaper reports it.
Everything also **drifts**. Public opinion fades, order and stability erode, unrest creeps up, and needs grow. Standing still loses ground, so a hands-off term is a slow slide, not a safe harbour.
## The final review
The term ends with a review, drawn from the average of the whole term and not only its last day.
- A score out of 100, weighted toward opinion, stability and economy, then adjusted for final debt, average growth and unemployment.
- A grade: **S**, **A**, **B**, **C**, **D** or **F**. S is rare by design. A term that ends early is always an F.
- A title from the historians, and a verdict that depends on how you ruled.
- Charts of opinion, stability, economy and debt, and the decisions that defined the term.
## Saves
The game **autosaves** in your browser, and the autosave is removed when a game ends. The pause menu offers **three manual slots** (Save game).
- To keep a game safely, use **Export**: it gives a text string starting with `CG1:`, or a `.sav` file. **Import** takes either back, on any device.
- If your browser blocks storage (a private window, for instance), Settings warns you. Export instead.
- At the start you are asked how the press should refer to you: **He / Him**, **She / Her** or **They / Them**. This only changes how the historian writes about you.
## Controls
| Control | Effect |
|---|---|
| Space | Pause or resume |
| 1, 2, 3 | Speed 1x, 2x, 4x |
| Y / N | Approve or reject the top proposal |
| Esc | Open the menu (time stops while it is open) |
## Advice
- Balance the books, but do not be shy: bold investment pays up to a point, then the debt calls.
- Do not ignore the army. A neglected army is a coup waiting for a date.
- Police is cheap and quick, but it costs civil rights.
- Every tax has a ceiling. Push past it and you earn less while angering everyone.
- Keep an eye on the intelligence summary: it speaks of what the gauges hide.
- Answer telegrams first.
- Read the projection in the ledger before you sign.
- Slow sectors need an early start: education funded in year four helps no one.
+174
View File
@@ -0,0 +1,174 @@
# Le Bureau : Règles
Vous gouvernez la République de Valdorie pendant cinq ans, depuis un bureau en bois. Des dossiers arrivent, vous les signez ou vous les refusez, vous fixez le budget, et le pays réagit. Cette fiche explique ce qui se trouve sur le bureau et comment fonctionne la mécanique qui se cache derrière.
---
## Votre mission
Gouvernez pendant tout le mandat, soit **1826 jours**, sans être chassé du pouvoir. Chaque décision porte votre signature, et la plupart ont un prix qui n'arrive que plus tard.
À la fin du mandat, un bilan est établi : une note de **S** à **F**, un titre décerné par les historiens et des graphiques de la période. **La dernière année compte pour moitié dans le score** : un essor acheté à crédit ne rapporte donc rien. On vous juge sur le pays que vous laissez.
> Le jeu ne dévoile jamais ses formules. Lisez les dossiers, surveillez les jauges, et tirez les leçons de ce qui arrive.
## Le bureau
Quatre éléments sont posés devant vous. Sur téléphone, ils se partagent l'écran, et une étagère en haut permet de passer de la Corbeille à la Note, au Journal et au Calendrier.
### La note ministérielle
La *Note ministérielle* est votre tableau de bord. Elle comprend trois parties.
- Sept **indicateurs**, chacun avec une jauge et une petite courbe de son évolution récente.
- Les **chiffres économiques** : PIB, croissance, chômage, inflation, dette et déficit en part du PIB, taux d'intérêt, chacun avec une flèche de tendance.
- La **synthèse du renseignement**, qui couvre ce que les jauges ne peuvent pas vous montrer (voir plus bas).
### La corbeille d'arrivée
La *Corbeille d'arrivée* contient les propositions qui attendent votre signature. Celle du dessus est celle à laquelle vous répondez. Quand elle est vide, le pays poursuit simplement sa route.
### Le calendrier
Le calendrier affiche le jour en cours, le prochain dossier et la prochaine revue budgétaire. Le cadran d'horloge fait un tour par jour de jeu, et tourne plus vite aux vitesses supérieures. Les boutons de vitesse, la pause et le Menu s'y trouvent aussi.
### Le journal
La presse raconte ce qui a bougé : indicateurs, croissance, emploi, prix, dette, et état de l'économie mondiale. C'est souvent là que l'on apprend qu'un creux ou un essor vient de commencer.
## Les sept indicateurs
Chacun va de 0 à 100. Ils évoluent lentement, s'influencent mutuellement, et tous glissent vers le bas si vous les laissez faire.
| Indicateur | Ce qu'il mesure | Ce qui l'influence |
|---|---|---|
| Ordre public | Rues calmes, État qui fonctionne | Police, justice, administration ; baisse avec le chômage et l'agitation |
| Stabilité | Solidité des institutions | Social, santé, logement, justice ; baisse avec la dette, le chômage, l'inflation |
| Droits civiques | Libertés et traitement équitable | Justice ; baisse avec une police lourde, une grosse armée et les coups de force |
| Opinion publique | Humeur de la population | Santé, social, logement, emploi, croissance ; baisse avec la hausse des prix et des impôts lourds |
| Économie | Vigueur de l'appareil productif | Industrie, infrastructures, recherche, éducation ; croissance et emploi |
| Puissance militaire | Poids des armes | L'armée avant tout, puis la recherche et l'industrie |
| Influence | Rayonnement à l'étranger | Culture, diplomatie, droits civiques |
### La synthèse du renseignement
Deux jauges vous sont cachées, et la synthèse les décrit avec des mots. Une troisième ligne dit si votre fauteuil est solide.
- **L'agitation** se lit Calme, Agité, Tendu, Volatil ou Explosif. Elle monte avec le chômage, l'inflation et une opinion hostile.
- **La loyauté de l'armée** se lit Mutinée, Hésitante, Stable ou Loyale. Elle suit le budget de l'armée, mais aussi la dette, l'inflation et l'humeur générale.
- **L'emprise sur le pouvoir** se lit Solide, Fragile ou Critique. Fragile signifie que vous êtes près d'une zone de danger. Critique, que vous y êtes.
## Le temps
Un jour de jeu dure **10 secondes** à la vitesse 1. Dans les Réglages, *Temps ralenti* porte le jour à 60 secondes.
- **Vitesses** : 1x, 2x et 4x, plus la pause. Espace met en pause et reprend ; les touches 1, 2 et 3 choisissent la vitesse.
- Le jeu continue de tourner si l'onglet du navigateur passe en arrière-plan. Le titre de l'onglet indique combien de propositions vous attendent.
- Le jeu s'arrête de lui-même pour la revue budgétaire, pour les télégrammes urgents, et quand la corbeille est pleine (voir plus bas).
## Les propositions
Chaque **lundi**, une nouvelle proposition arrive dans la corbeille. Elle prend la forme d'un mémorandum, d'un rapport, d'une lettre, d'une pétition ou d'un télégramme, et elle nomme son expéditeur.
Les propositions **s'empilent**. Le jeu ne s'arrête pas pour chacune : le temps continue de couler pendant que la pile grossit. Vous répondez à celle du dessus quand vous le décidez.
### Répondre
Apposez le tampon **Approuvé** ou **Rejeté**, avec les boutons ou avec les touches **Y** et **N**. Les deux réponses ont un coût : il n'existe pas de choix gratuit, et refuser est une décision comme une autre.
Le jeu **ne se met en pause que lorsque 10 propositions attendent**, et non au bout de 10 semaines, et il y reste jusqu'à ce que vous en traitiez une.
### Conséquences
Les réponses ont des effets durables, et beaucoup arrivent en retard. Certaines déclenchent une proposition de suivi quelques semaines plus tard : une promesse arrive à échéance, un refus devient une pétition, un pari tourne mal.
Une partie tire environ 260 propositions dans un fonds de plus de 900 : deux mandats ne se ressemblent jamais. Les télégrammes de crise sont à part : ils réagissent à l'état du pays et aux choix extrêmes. Affamez l'armée, pressez les contribuables ou armez la police jusqu'aux dents, et les télégrammes s'en apercevront.
## Le budget
Au jour 0, puis **chaque trimestre (13 semaines)**, le *Registre du Trésor* s'ouvre et le jeu se met en pause. Entre deux revues, le registre est fermé : le budget que vous avez classé est celui avec lequel vous vivez. Rien ne prend effet avant d'appuyer sur *Signer et classer*, et *Réinitialiser* revient aux chiffres d'ouverture du registre.
### Les secteurs
Quinze secteurs se partagent les dépenses : éducation, santé, social et retraites, police, armée, justice, industrie et énergie, transports publics, infrastructures, logement, recherche, culture et médias, diplomatie, environnement, administration.
Chacun se règle en **part du PIB**. Un repère sur chaque ligne marque ce dont le secteur a **besoin**, et les besoins augmentent un peu chaque année : un budget figé achète donc un peu moins chaque fois.
Chaque secteur a une **qualité** de 0 à 100 qui suit le budget avec retard. La police réagit en quelques semaines, la santé en quelques mois, l'éducation et les infrastructures seulement en plusieurs années. Au-dessus d'environ **70**, un secteur apporte des bénéfices supplémentaires ; en dessous d'environ **40**, il commence à faire mal. Les rendements sont décroissants : les dernières pièces sont celles qui rapportent le moins.
### Les impôts
Trois impôts se règlent : l'**impôt sur le revenu**, l'**impôt sur les sociétés** et la **TVA**. Pour chacun, les recettes montent avec le taux jusqu'à un sommet, marqué par un repère, puis **retombent**. Au-delà, vous encaissez moins et vous payez encore en opinion et en croissance. Chaque impôt blesse à sa façon : la TVA est la plus ressentie par la population, l'impôt sur les sociétés pèse le plus sur la croissance.
### La projection
Le registre affiche une synthèse du Trésor (recettes, dépenses sectorielles, intérêts de la dette, excédent ou déficit) et une **projection sur quatre trimestres** du déficit et de la dette. Elle suppose que rien d'autre ne change : elle ne peut donc pas prévoir le cycle mondial ni les propositions à venir.
## La dette
Chaque jour, le déficit s'ajoute à la dette, et un excédent la réduit. La dette se mesure par rapport au PIB.
- **Sous 60 %**, la dette est libre : la porter ne coûte rien de plus.
- **Au-dessus de 60 %**, une prime d'intérêt apparaît et grimpe rapidement. À partir d'environ **100 %**, c'est dangereux, et des paniques obligataires ou des dégradations de note peuvent arriver sous forme de propositions.
- À **135 %**, un télégramme d'avertissement arrive.
- **Au-dessus de 165 %**, le pays fait défaut et votre mandat s'arrête aussitôt.
Les intérêts rognent l'argent que vous préféreriez consacrer aux secteurs : la dette se nourrit d'elle-même.
## Les façons de perdre le pouvoir
Quatre événements mettent fin à la partie sur-le-champ, chacun avec sa propre une de journal. Le risque augmente chaque jour passé dans la zone de danger : en sortir vite compte plus que d'en sortir parfaitement.
| Fin | Déclencheur | Avertissement |
|---|---|---|
| Révolution | Agitation au-dessus de 62 et opinion publique sous 28 | Télégramme urgent : la foule aux portes |
| Coup d'État militaire | Loyauté de l'armée sous 35 avec une stabilité sous 50, ou loyauté sous 18 à elle seule | Télégramme urgent : les généraux s'impatientent |
| Destitution | Droits civiques sous 10 et opinion publique sous 25, pendant 60 jours d'affilée | Télégramme urgent : une motion de défiance |
| Défaut de paiement | Dette au-dessus de 165 % du PIB | Télégramme urgent à 135 % : les prêteurs se retirent |
### Les alertes
Entrer dans une zone de danger envoie un **télégramme urgent**, et le jeu se met en pause jusqu'à ce que vous l'ayez lu. Une **Séance d'urgence** ouvre alors aussitôt le registre, hors du calendrier trimestriel, pour que vous puissiez corriger la trajectoire sans attendre. Elle a lieu au plus une fois tous les 45 jours. Une révolution ou un coup d'État n'est pas certain dès le premier jour, mais il ne fait que se rapprocher.
## Le monde
Hors de vos frontières, un **cycle économique mondial** alterne creux et essors. Il pèse sur la croissance, l'inflation et les recettes quoi que vous fassiez, et un creux peut transformer un budget équilibré en déficit. Le journal en rend compte.
Tout **dérive** aussi. L'opinion s'érode, l'ordre et la stabilité s'effritent, l'agitation gagne du terrain, et les besoins croissent. Rester immobile fait perdre du terrain : un mandat sans action est une lente glissade, pas un port tranquille.
## Le bilan final
Le mandat se termine par un bilan, établi sur la moyenne de tout le mandat et pas seulement sur son dernier jour.
- Un score sur 100, pondéré surtout par l'opinion, la stabilité et l'économie, puis ajusté selon la dette finale, la croissance moyenne et le chômage.
- Une note : **S**, **A**, **B**, **C**, **D** ou **F**. Le S est rare par construction. Un mandat interrompu en cours de route vaut toujours un F.
- Un titre décerné par les historiens, et un verdict qui dépend de votre façon de gouverner.
- Des graphiques de l'opinion, de la stabilité, de l'économie et de la dette, et les décisions qui ont marqué le mandat.
## Sauvegardes
Le jeu fait une **sauvegarde auto** dans votre navigateur, supprimée quand une partie se termine. Le menu pause propose **trois emplacements manuels** (Sauvegarder).
- Pour conserver une partie en sûreté, utilisez **Exporter** : vous obtenez une chaîne de texte commençant par `CG1:`, ou un fichier `.sav`. **Importer** reprend l'un ou l'autre, sur n'importe quel appareil.
- Si votre navigateur bloque le stockage (fenêtre privée, par exemple), les Réglages vous en avertissent. Exportez alors.
- Au début, on vous demande comment la presse doit s'adresser à vous : **Il / Lui**, **Elle / Elle** ou **Iel / Ielle**. Cela ne change que la manière dont l'historien parle de vous.
## Commandes
| Commande | Effet |
|---|---|
| Espace | Pause ou reprise |
| 1, 2, 3 | Vitesse 1x, 2x, 4x |
| Y / N | Approuver ou rejeter la proposition du dessus |
| Échap | Ouvrir le menu (le temps s'arrête tant qu'il est ouvert) |
## Conseils
- Équilibrez les comptes, mais sans timidité : l'investissement audacieux paie jusqu'à un certain point, puis la dette réclame son dû.
- N'oubliez pas l'armée. Une armée négligée est un coup d'État qui attend sa date.
- La police est peu chère et rapide, mais elle coûte des droits civiques.
- Chaque impôt a un plafond. Le dépasser rapporte moins et irrite tout le monde.
- Gardez un œil sur la synthèse du renseignement : elle dit ce que les jauges cachent.
- Répondez d'abord aux télégrammes.
- Lisez la projection du registre avant de signer.
- Les secteurs lents demandent de l'avance : l'éducation financée en quatrième année ne profite à personne.
+5 -1
View File
@@ -2,7 +2,8 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#26322c">
<title>The Desk</title>
<link rel="stylesheet" href="css/desk.css">
<link rel="stylesheet" href="css/papers.css">
@@ -11,10 +12,13 @@
<link rel="stylesheet" href="css/decision.css">
<link rel="stylesheet" href="css/endgame.css">
<link rel="stylesheet" href="css/menu.css">
<link rel="stylesheet" href="css/anim.css">
<link rel="stylesheet" href="css/mobile.css">
</head>
<body>
<main id="desk">
<div id="pad">
<nav id="shelf" class="shelf" aria-label="Display"></nav>
<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>
+25 -16
View File
@@ -51,12 +51,15 @@ 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 waiting = new Set(state.inbox.map((e) => e.id));
// the last few categories seen, answered or still in the tray
const recent = [...state.cardHistory, ...state.inbox].slice(-bal.cards.recentWindow).map((h) => h.category);
const pool = [];
const weights = [];
for (const card of cards) {
if (card.followUpOnly) continue;
if (card.followUpOnly || waiting.has(card.id)) continue;
if (card.scenarios && !card.scenarios.includes(state.scenarioId)) continue;
const last = lastAnswered.get(card.id);
if (last !== undefined) {
if (card.once !== false) continue;
@@ -70,28 +73,34 @@ export function drawCard(state, bal, cards) {
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.
// Called when a new week starts (state.pending.weeklyCard): draws the week's proposal and puts it
// at the bottom of the in-tray (state.inbox). A due follow-up always comes first. With no eligible
// card the week is simply empty. Proposals pile up without stopping the clock; the game only waits
// once the tray holds bal.cards.inboxMax of them (pending.inboxFull), until one is answered.
export function ensureCard(state, bal, cards) {
if (!state.pending.weeklyCard || state.pendingCard) return null;
if (!state.pending.weeklyCard) return null;
state.pending.weeklyCard = false;
const byId = indexCards(cards);
const waiting = new Set(state.inbox.map((e) => e.id));
let card = null;
const due = state.followUps.findIndex((f) => f.due <= state.day);
const due = state.followUps.findIndex((f) => f.due <= state.day && !waiting.has(f.id));
if (due >= 0) card = byId.get(state.followUps.splice(due, 1)[0].id) ?? null;
card ??= drawCard(state, bal, cards);
if (!card) return null;
if (!card) {
state.pending.weeklyCard = false;
return null;
}
state.pendingCard = { id: card.id, day: state.day };
state.inbox.push({ id: card.id, day: state.day, category: card.category });
if (state.inbox.length >= bal.cards.inboxMax) state.pending.inboxFull = true;
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);
// Answers the proposal `id` (default: the one on top of the tray, the oldest).
export function answerCard(state, bal, cards, choice, id) {
if (choice !== "yes" && choice !== "no") return { ok: false };
const at = id === undefined ? 0 : state.inbox.findIndex((e) => e.id === id);
const entry = state.inbox[at];
if (!entry) return { ok: false };
const card = indexCards(cards).get(entry.id);
if (!card) return { ok: false };
const option = card[choice];
@@ -101,7 +110,7 @@ export function answerCard(state, bal, cards, choice) {
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;
state.inbox.splice(at, 1);
state.pending.inboxFull = state.inbox.length >= bal.cards.inboxMax;
return { ok: true, card };
}
+4 -3
View File
@@ -47,11 +47,12 @@ export function createGame({ scenario, balance, seed = Date.now(), countryName,
world: { cycle: 0 }, // global business cycle, see world.js
flags: {},
cardHistory: [],
pendingCard: null,
inbox: [], // proposals waiting in the tray, oldest first: { id, day, category }
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 },
// Any true flag here pauses time (see advanceDay). weeklyCard is momentary: the caller answers it
// at once with ensureCard(); inboxFull stays until the tray is below its limit.
pending: { profile: !PRONOUNS.includes(pronouns), weeklyCard: true, review: true, alert: false, inboxFull: false },
over: null,
// days in each danger zone; emergency = an unscheduled budget session is open (see advanceDay)
risk: { revolution: 0, coup: 0, impeachment: 0, defaultZone: false, emergency: false, lastEmergency: -999 },
+1 -1
View File
@@ -1,6 +1,6 @@
// Shared constants and tiny helpers. No game logic here.
export const STATE_VERSION = 2;
export const STATE_VERSION = 3;
export const IND_IDS = [
"order", "stability", "civilRights", "opinion", "economy",
+5 -5
View File
@@ -5,11 +5,11 @@ export function createClock() {
}
// 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;
// dt is capped (capMs) so a frozen tab doesn't fast-forward the game without limit when it wakes
// up, 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, capMs = 1000) {
clock.acc += Math.max(0, Math.min(dtMs, capMs)) * speed;
const days = Math.floor(clock.acc / msPerDay);
clock.acc -= days * msPerDay;
return days;
+8 -3
View File
@@ -9,7 +9,7 @@ const DEV_KEYS = ["growth", "unemployment", "inflation", "debt", "deficit", "tax
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 PENDING_KINDS = ["profile", "weeklyCard", "review", "alert", "inboxFull"];
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;
@@ -73,7 +73,11 @@ export function restoreState(s, { scenarios, balance, cards }) {
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();
if (!Array.isArray(s.inbox) || s.inbox.length > balance.cards.inboxMax) return fail();
for (const e of s.inbox) {
if (!isObj(e) || typeof e.id !== "string" || !isInt(e.day, 0, balance.time.termDays)) return fail();
if (e.category !== undefined && typeof e.category !== "string") return fail();
}
// Pauses, alerts and endings
if (!isObj(s.pending)) return fail();
@@ -95,7 +99,8 @@ export function restoreState(s, { scenarios, balance, cards }) {
// 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.inbox = s.inbox.filter((e) => cardIds.has(e.id));
s.pending.inboxFull = s.inbox.length >= balance.cards.inboxMax;
s.pending.alert = s.alerts.length > 0;
return { ok: true, state: s };
}
+17 -1
View File
@@ -186,5 +186,21 @@
"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."
"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.",
"inbox.full": "The tray is full. Time is stopped until you answer one.",
"shelf.nav": "Choose a display",
"shelf.tray": "Tray",
"shelf.brief": "Briefing",
"shelf.news": "News",
"shelf.clock": "Calendar",
"shelf.ledger": "Ledger",
"shelf.menu": "Menu",
"shelf.waiting": "{n} waiting",
"menu.rules": "Rules",
"rules.title": "Rules of the desk",
"rules.loading": "Opening the rule book…",
"rules.error": "The rule book could not be opened.",
"settings.sound": "Sounds",
"settings.soundHint": "Soft effects: stamps, paper, telegrams.",
"settings.volume": "Volume"
}
+17 -1
View File
@@ -186,5 +186,21 @@
"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."
"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.",
"inbox.full": "La corbeille est pleine. Le temps est arrêté tant que vous n'en traitez pas une.",
"shelf.nav": "Choisir l'affichage",
"shelf.tray": "Corbeille",
"shelf.brief": "Note",
"shelf.news": "Journal",
"shelf.clock": "Calendrier",
"shelf.ledger": "Registre",
"shelf.menu": "Menu",
"shelf.waiting": "{n} en attente",
"menu.rules": "Règles",
"rules.title": "Règlement du bureau",
"rules.loading": "Ouverture du règlement…",
"rules.error": "Le règlement n'a pas pu être ouvert.",
"settings.sound": "Sons",
"settings.soundHint": "Effets discrets : tampons, papier, télégrammes.",
"settings.volume": "Volume"
}
+128 -33
View File
@@ -1,9 +1,13 @@
// Boot, game lifecycle (menu, new game, load, autosave), panel wiring and the real-time loop.
// The loop is driven by animation frames while the tab is visible and by a worker heartbeat while it
// is hidden, so the game keeps running in the background (it still stops at a budget review, a danger
// warning, or when the in-tray holds its maximum of proposals).
//
// 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)
// ?msPerDay=N (real milliseconds per in-game day at speed 1, for tests) ?untilAlert (stop ?skip at the first warning)
import {
createGame, advanceDay, setBudget, setSpeed, setPronouns, dismissAlert, isWaiting,
@@ -20,6 +24,8 @@ import { createLedger } from "./ui/ledger.js";
import { createTelegram } from "./ui/telegram.js";
import { createEndgame } from "./ui/endgame.js";
import { createMenu } from "./ui/menu.js";
import { createShelf } from "./ui/shelf.js";
import { createAudio } from "./ui/audio.js";
const params = new URLSearchParams(location.search);
const getJson = (url) => fetch(url).then((r) => r.json());
@@ -48,6 +54,8 @@ let settings = loadSettings(storage);
const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en";
await initI18n(params.get("lang") ?? settings.lang ?? browserLang);
const audio = createAudio({ enabled: () => settings.sound, volume: () => settings.volume });
const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"];
const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu");
@@ -58,6 +66,8 @@ let scenario = null;
let panels = null;
let menuOpen = true;
let lastSpeed = 1;
let view = "tray"; // phone layout: which paper is on the desk (tray, brief, news, clock, ledger)
let seen = null; // pending flags and tray size at the last change, to play sounds on transitions
const el = {
dash: document.getElementById("dashboard"),
@@ -68,13 +78,39 @@ const el = {
cal: document.getElementById("calendar"),
news: document.getElementById("newspaper"),
menu: document.getElementById("menu"),
shelf: document.getElementById("shelf"),
};
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.
// A new week brings a proposal to the tray; with none eligible the week is simply empty.
function prepareWeek() {
ensureCard(state, balance, cards);
if (state.pending.weeklyCard) ensureCard(state, balance, cards);
}
function updateTitle() {
const base = t("app.title");
if (!state || state.over) {
document.title = base;
return;
}
const waiting = document.hidden && isWaiting(state) && !state.pending.profile;
const n = state.inbox.length;
document.title = `${waiting ? "● " : ""}${n ? `(${n}) ` : ""}${base}`;
}
// Sounds for what just changed: a new proposal, a budget review, a warning, a full tray, the end.
function notify() {
const p = state.pending;
const now = { review: p.review, alert: p.alert, inboxFull: p.inboxFull, over: Boolean(state.over), inbox: state.inbox.length };
if (seen) {
if (now.over && !seen.over) audio.play(state.over.type === "term" ? "fanfare" : "fall");
else if (now.alert && !seen.alert) audio.play("alert");
else if (now.review && !seen.review) audio.play("page");
else if (now.inboxFull && !seen.inboxFull) audio.play("chime");
else if (now.inbox > seen.inbox) audio.play("paper");
}
seen = now;
}
function autosave() {
@@ -87,13 +123,19 @@ function autosave() {
function build() {
panels?.decision.destroy();
document.title = t("app.title");
state.lang = getLang();
pad.classList.add("is-dealing"); // the papers land on the desk
setTimeout(() => pad.classList.remove("is-dealing"), 1100);
panels = {
dash: createDashboard(el.dash, scenario),
decision: createDecision(el.folder, { cardsById, characters, onAnswer, onProfile, keysEnabled: () => !menuOpen }),
ledger: createLedger(el.ledger, scenario, { onConfirm: render }),
decision: createDecision(el.folder, {
cardsById, characters, inboxMax: balance.cards.inboxMax, onAnswer, onProfile,
onStamp: (choice) => audio.play(choice === "yes" ? "stampYes" : "stampNo"),
keysEnabled: () => !menuOpen,
}),
ledger: createLedger(el.ledger, scenario, { onConfirm: () => { audio.play("sign"); render(); } }),
cal: createCalendar(el.cal, balance, { onSpeed, onMenu: openMenu }),
shelf: createShelf(el.shelf, balance, { getView: () => view, onView: setView, 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 }),
@@ -101,12 +143,34 @@ function build() {
render();
}
// Phone layout: the ledger takes the screen when a review opens and hands it back to the tray afterwards.
function syncView(ledgerOpen) {
if (ledgerOpen && !seen?.review) view = "ledger";
else if (!ledgerOpen && view === "ledger") view = "tray";
if (state.pending.inboxFull && !seen?.inboxFull && !ledgerOpen) view = "tray";
pad.dataset.view = view;
}
function setView(next) {
view = next;
pad.dataset.view = view;
panels?.shelf.update(state, balance);
audio.play("click");
}
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));
const ledgerOpen = Boolean(state.pending.review && !state.pending.profile && !state.over);
syncView(ledgerOpen);
notify();
// A hidden tab keeps playing but skips the drawing; it redraws everything when it comes back.
if (!document.hidden) {
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", ledgerOpen);
pad.classList.toggle("is-endgame", Boolean(state.over));
}
updateTitle();
autosave();
}
@@ -128,6 +192,9 @@ function enterGame(next) {
scenario = scenarios[state.scenarioId];
if (params.has("dev")) window.__game = state;
lastSpeed = state.speed || 1;
clock.acc = 0;
view = "tray";
seen = null;
prepareWeek();
closeMenu();
build();
@@ -144,7 +211,6 @@ function closeMenu() {
menuOpen = false;
pad.classList.remove("is-menu");
menu.hide();
clock.acc = 0;
}
function quitToMenu() {
@@ -152,7 +218,8 @@ function quitToMenu() {
panels?.decision.destroy();
state = null;
panels = null;
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news"]) el[id].innerHTML = "";
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news", "shelf"]) el[id].innerHTML = "";
document.title = t("app.title");
el.ledger.hidden = true;
el.alerts.hidden = true;
el.endgame.hidden = true;
@@ -166,6 +233,7 @@ function applySettings(patch) {
const langChanged = patch.lang && patch.lang !== getLang();
settings = { ...settings, ...patch };
saveSettings(storage, settings);
if (patch.sound === true || patch.volume !== undefined) audio.play("click");
if (!langChanged) return;
setLang(patch.lang);
document.title = t("app.title");
@@ -192,11 +260,12 @@ const menu = createMenu(el.menu, {
function onSpeed(speed) {
if (speed > 0) lastSpeed = speed;
setSpeed(state, balance, speed);
audio.play("click");
render();
}
function onAnswer(choice) {
answerCard(state, balance, cards, choice);
function onAnswer(choice, id) {
answerCard(state, balance, cards, choice, id);
render();
}
@@ -207,6 +276,7 @@ function onProfile(pronouns) {
function onDismiss() {
dismissAlert(state);
audio.play("paper");
render();
}
@@ -224,7 +294,11 @@ document.addEventListener("keydown", (e) => {
});
addEventListener("pagehide", autosave);
document.addEventListener("visibilitychange", () => { if (document.hidden) autosave(); });
document.addEventListener("visibilitychange", () => {
if (document.hidden) autosave();
else if (state) render(); // redraw what was skipped while hidden
updateTitle();
});
// ---- Dev shortcuts ----
@@ -240,13 +314,12 @@ function devGame() {
s.pending.review = false;
while (s.pending.alert) dismissAlert(s);
prepareWeek();
if (s.pendingCard) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
while (s.inbox.length) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
advanceDay(s, balance);
if (params.has("untilAlert") && s.pending.alert) break; // stop on the first danger warning
}
if (cardsById.has(params.get("card"))) {
s.pendingCard = { id: params.get("card"), day: s.day };
s.pending.weeklyCard = true;
s.inbox.unshift({ id: params.get("card"), day: s.day, category: cardsById.get(params.get("card")).category });
}
return s;
}
@@ -259,25 +332,47 @@ let last = performance.now();
if (devRun) enterGame(devGame());
else quitToMenu();
function frame(now) {
// One step of the game: turns elapsed real time into in-game days. Returns true when a day passed.
// A long gap since the last step (a frozen background tab waking up) is caught up within a limit.
function step(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;
if (!state || menuOpen || state.speed <= 0 || state.over || isWaiting(state)) return false;
let days = consumeClock(clock, dt, msPerDay(), state.speed, balance.time.catchUpMs);
let advanced = false;
while (days-- > 0) {
const r = advanceDay(state, balance);
if (!r.advanced) break;
advanced = true;
if (r.events.weeklyCard) prepareWeek();
if (!document.hidden && state.speed <= 2) audio.play(state.day % 2 ? "tick" : "tock");
if (isWaiting(state) || state.over) break;
}
if (advanced) render();
return advanced;
}
const devMsPerDay = devRun && params.has("msPerDay") ? Number(params.get("msPerDay")) : 0; // dev: faster days for tests
const msPerDay = () => devMsPerDay || (settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay);
function frame(now) {
step(now);
if (panels) {
const p = Math.min(1, clock.acc / msPerDay());
panels.cal.clockHand(p);
panels.shelf.clockHand(p);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// Background play: animation frames stop in a hidden tab, so a worker's timer keeps the game going.
function startHeartbeat() {
const beat = () => { if (document.hidden) step(performance.now()); };
try {
new Worker(new URL("./timer-worker.js", import.meta.url)).onmessage = beat;
} catch {
setInterval(beat, 1000); // no worker support: the slower main-thread timer still works
}
}
startHeartbeat();
+12 -1
View File
@@ -46,6 +46,13 @@ export function encodeSave(state) {
export const MIGRATIONS = {
// v2 added the world cycle: older saves start it at neutral.
1: (s) => ({ ...s, version: 2, world: { cycle: 0 } }),
// v3 replaced the single pending card by the in-tray queue.
2: ({ pendingCard, ...s }) => ({
...s,
version: 3,
inbox: pendingCard ? [{ id: pendingCard.id, day: pendingCard.day }] : [],
pending: { ...s.pending, weeklyCard: Boolean(s.pending?.weeklyCard) && !pendingCard, inboxFull: false },
}),
};
export function migrate(state) {
@@ -55,7 +62,11 @@ export function migrate(state) {
while (s.version < STATE_VERSION) {
const step = MIGRATIONS[s.version];
if (!step) return { ok: false, error: "version" };
s = step(s);
try {
s = step(s);
} catch {
return { ok: false, error: "format" }; // a damaged or hostile save must never throw
}
}
return { ok: true, state: s };
}
+3 -1
View File
@@ -88,7 +88,7 @@ export function readSave(store, key) {
// ---- Settings ----
const DEFAULT_SETTINGS = { lang: null, slow: false };
const DEFAULT_SETTINGS = { lang: null, slow: false, sound: true, volume: 0.6 };
export function loadSettings(store) {
try {
@@ -96,6 +96,8 @@ export function loadSettings(store) {
return {
lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang,
slow: s.slow === true,
sound: s.sound !== false,
volume: typeof s.volume === "number" && s.volume >= 0 && s.volume <= 1 ? s.volume : DEFAULT_SETTINGS.volume,
};
} catch {
return { ...DEFAULT_SETTINGS };
+3
View File
@@ -0,0 +1,3 @@
// A heartbeat for the game loop. Browsers stop animation frames in a background tab and slow
// down its timers, but a worker's timer keeps going, so the game can keep running while hidden.
setInterval(() => postMessage(0), 100);
+136
View File
@@ -0,0 +1,136 @@
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft
// (slow attacks, low levels, filtered noise) so it stays in the background of the desk.
// Browsers keep audio locked until the first click or key press; unlock() resumes it.
export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
let ctx = null;
let master = null;
let noiseBuf = null;
function context() {
if (!ctx) {
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
if (!AC) return null;
try {
ctx = new AC();
} catch {
return null;
}
master = ctx.createGain();
master.connect(ctx.destination);
}
return ctx;
}
function unlock() {
const c = context();
if (c && c.state === "suspended") c.resume?.().catch?.(() => {});
}
for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, unlock, { passive: true });
// One note: a sine (or other) wave with an attack and an exponential fade.
function tone(c, at, { freq, to, type = "sine", dur = 0.2, gain = 0.2, attack = 0.008 }) {
const o = c.createOscillator();
const g = c.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, at);
if (to) o.frequency.exponentialRampToValueAtTime(to, at + dur);
g.gain.setValueAtTime(0.0001, at);
g.gain.linearRampToValueAtTime(gain, at + attack);
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
o.connect(g).connect(master);
o.start(at);
o.stop(at + dur + 0.05);
}
// Filtered noise: paper, pages, thumps and clicks.
function noise(c, at, { dur = 0.15, gain = 0.1, type = "bandpass", freq = 2000, to, q = 0.8, attack = 0.02 }) {
noiseBuf ??= (() => {
const b = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = b.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return b;
})();
const src = c.createBufferSource();
src.buffer = noiseBuf;
src.loop = true;
const f = c.createBiquadFilter();
f.type = type;
f.Q.value = q;
f.frequency.setValueAtTime(freq, at);
if (to) f.frequency.exponentialRampToValueAtTime(to, at + dur);
const g = c.createGain();
g.gain.setValueAtTime(0.0001, at);
g.gain.linearRampToValueAtTime(gain, at + attack);
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
src.connect(f).connect(g).connect(master);
src.start(at);
src.stop(at + dur + 0.05);
}
const SOUNDS = {
stampYes: (c, t) => {
tone(c, t, { freq: 150, to: 55, dur: 0.16, gain: 0.5 });
noise(c, t, { type: "lowpass", freq: 500, dur: 0.07, gain: 0.25, attack: 0.004 });
noise(c, t, { freq: 2200, dur: 0.04, gain: 0.07, attack: 0.004 });
},
stampNo: (c, t) => {
tone(c, t, { freq: 118, to: 44, dur: 0.18, gain: 0.5 });
noise(c, t, { type: "lowpass", freq: 420, dur: 0.08, gain: 0.28, attack: 0.004 });
noise(c, t, { freq: 1800, dur: 0.04, gain: 0.07, attack: 0.004 });
},
paper: (c, t) => noise(c, t, { freq: 2600, to: 1200, dur: 0.17, gain: 0.1, attack: 0.04 }),
page: (c, t) => {
noise(c, t, { freq: 500, to: 3200, dur: 0.24, gain: 0.11, attack: 0.05 });
noise(c, t + 0.2, { freq: 2400, dur: 0.06, gain: 0.05, attack: 0.01 });
},
sign: (c, t) => {
noise(c, t, { freq: 3400, to: 2600, dur: 0.2, gain: 0.05, q: 2, attack: 0.05 });
tone(c, t + 0.22, { freq: 140, to: 60, dur: 0.14, gain: 0.3 });
},
telegram: (c, t) => {
for (let i = 0; i < 4; i++) noise(c, t + i * 0.07, { type: "highpass", freq: 3200, dur: 0.02, gain: 0.1, attack: 0.002 });
tone(c, t + 0.34, { freq: 1320, dur: 0.7, gain: 0.1, attack: 0.01 });
tone(c, t + 0.34, { freq: 1980, dur: 0.45, gain: 0.04, attack: 0.01 });
},
alert: (c, t) => {
tone(c, t, { freq: 233, dur: 0.4, gain: 0.2, type: "triangle", attack: 0.03 });
tone(c, t + 0.24, { freq: 175, dur: 0.55, gain: 0.2, type: "triangle", attack: 0.03 });
},
chime: (c, t) => {
tone(c, t, { freq: 659, dur: 0.7, gain: 0.12, attack: 0.02 });
tone(c, t + 0.16, { freq: 880, dur: 0.9, gain: 0.1, attack: 0.02 });
},
tick: (c, t) => tone(c, t, { freq: 1100, to: 900, dur: 0.035, gain: 0.03, attack: 0.002 }),
tock: (c, t) => tone(c, t, { freq: 820, to: 700, dur: 0.035, gain: 0.03, attack: 0.002 }),
click: (c, t) => {
tone(c, t, { freq: 900, to: 600, dur: 0.04, gain: 0.06, attack: 0.002 });
noise(c, t, { type: "highpass", freq: 3000, dur: 0.015, gain: 0.04, attack: 0.002 });
},
fall: (c, t) => {
[330, 247, 196, 147].forEach((f, i) => tone(c, t + i * 0.45, { freq: f, dur: 0.9, gain: 0.14, attack: 0.04, type: "triangle" }));
},
fanfare: (c, t) => {
[392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.14, { freq: f, dur: 1.1, gain: 0.09, attack: 0.03 }));
},
};
function play(name) {
if (!enabled()) return;
const make = SOUNDS[name];
const c = context();
if (!make || !c) return;
if (c.state === "suspended") {
c.resume?.().catch?.(() => {});
return; // still locked: nothing to hear yet
}
master.gain.value = Math.max(0, Math.min(1, volume()));
try {
make(c, c.currentTime + 0.005);
} catch {
/* a failing sound never stops the game */
}
}
return { play, unlock, names: Object.keys(SOUNDS) };
}
+23 -8
View File
@@ -1,17 +1,22 @@
// Desk calendar: in-game date, term progress, next decision dates, the brass speed
// controls and the way into the menu.
// Desk calendar: a dial clock that turns once per in-game day, the 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";
import { $, dialSvg, setDial } 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__top">
${dialSvg("cal__dial")}
<div class="cal__date">
<div class="cal__day"></div>
<div class="cal__month"></div>
</div>
</div>
<div class="cal__count"></div>
<div class="cal__term"><span></span></div>
<dl class="cal__next">
@@ -38,17 +43,27 @@ export function createCalendar(root, bal, { onSpeed, onMenu }) {
return n === 1 ? t("cal.tomorrow") : t("cal.inDays", { n });
};
const dial = $(root, ".cal__dial");
let lastDay = -1;
function update(state) {
const p = dateParts(dateOfDay(state.startDate, state.day).iso);
$(root, ".cal__weekday").textContent = p.weekday;
$(root, ".cal__day").textContent = p.day;
const dayEl = $(root, ".cal__day");
dayEl.textContent = p.day;
if (lastDay >= 0 && state.day !== lastDay && state.speed > 0 && state.speed <= 2) {
dayEl.classList.remove("is-flip");
void dayEl.offsetWidth; // restart the animation
dayEl.classList.add("is-flip");
}
lastDay = state.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="briefing"]').textContent = over ? "–" : when(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]")) {
@@ -56,5 +71,5 @@ export function createCalendar(root, bal, { onSpeed, onMenu }) {
}
}
return { update };
return { update, clockHand: (progress) => setDial(dial, progress) };
}
+9 -1
View File
@@ -63,6 +63,7 @@ export function createDashboard(root, scenario) {
spark: $(root, `.gauge[data-ind="${id}"] .gauge__spark`),
}]));
const sparkLen = {};
const shownValue = {};
function trend(series, eps, goodUp) {
if (!series || series.length < 2) return ["", ""];
@@ -82,7 +83,14 @@ export function createDashboard(root, scenario) {
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);
const r = Math.round(v);
g.value.textContent = r;
if (shownValue[id] !== undefined && shownValue[id] !== r) {
g.value.classList.remove("is-up", "is-down");
void g.value.offsetWidth; // restart the animation
g.value.classList.add(r > shownValue[id] ? "is-up" : "is-down");
}
shownValue[id] = r;
if (id === "opinion") g.band.textContent = t("opinion." + bandIndex(v, OPINION_CUTS));
const series = state.history.series[id] ?? [];
+48 -13
View File
@@ -1,26 +1,34 @@
// Centre folder: the weekly decision card as a paper document with two rubber
// stamps. Also shows the idle and end-of-term messages.
// Centre folder: the in-tray. The oldest waiting proposal is the document on top, answered with
// two rubber stamps; the others show as a pile of sheets under it. 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
const STAMP_MS = 600; // how long the stamp animation plays before the paper leaves the tray
const LEAVE_MS = 320; // and how long the paper takes to slide away
export function createDecision(root, { cardsById, characters, onAnswer, onProfile, keysEnabled = () => true }) {
export function createDecision(root, { cardsById, characters, inboxMax = 10, onAnswer, onStamp = () => {}, onProfile, keysEnabled = () => true }) {
root.innerHTML = `
<h2 class="inbox__tab">${t("inbox.title")}</h2>
<h2 class="inbox__tab">${t("inbox.title")} <span class="inbox__count" data-count></span></h2>
<p class="inbox__full" role="status" hidden>${t("inbox.full")}</p>
<div class="inbox__body"></div>`;
const body = $(root, ".inbox__body");
const countEl = $(root, "[data-count]");
const fullEl = $(root, ".inbox__full");
let shown = "";
let locked = false;
let latest = null;
let lastCount = 0;
let timers = [];
function choose(choice) {
if (locked) return;
const card = $(body, ".card");
if (!card) return;
const id = latest?.inbox[0]?.id;
if (!card || !id) return;
locked = true;
card.dataset.stamped = choice;
for (const b of card.querySelectorAll("button")) b.disabled = true;
@@ -28,7 +36,9 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
stamp.className = `card__stamp card__stamp--${choice}`;
stamp.textContent = t(choice === "yes" ? "card.approved" : "card.rejected");
card.append(stamp);
setTimeout(() => onAnswer(choice), STAMP_MS);
onStamp(choice);
timers.push(setTimeout(() => card.classList.add("card--leave"), STAMP_MS));
timers.push(setTimeout(() => onAnswer(choice, id), STAMP_MS + LEAVE_MS));
}
body.addEventListener("click", (e) => {
@@ -40,7 +50,7 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
// 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 (!keysEnabled() || !latest?.inbox.length) 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;
@@ -61,10 +71,10 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
function cardHtml(card, state) {
const from = characters[card.sender];
const date = dateParts(dateOfDay(state.startDate, state.pendingCard.day).iso);
const date = dateParts(dateOfDay(state.startDate, state.inbox[0].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)}">
<article class="card card--enter card--${esc(card.doc)}" data-pile="${Math.min(3, state.inbox.length - 1)}">
<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>
@@ -80,15 +90,34 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
</article>`;
}
// The tab counts the proposals waiting; the pile under the top paper grows with them.
function updateTray(state) {
const n = state.inbox.length;
countEl.textContent = n ? `${n}/${inboxMax}` : "";
countEl.classList.toggle("is-warn", n >= inboxMax - 2);
if (n > lastCount) {
countEl.classList.remove("is-bump");
void countEl.offsetWidth; // restart the animation
countEl.classList.add("is-bump");
}
lastCount = n;
fullEl.hidden = !(state.pending.inboxFull && !state.over);
const card = $(body, ".card");
if (card) card.dataset.pile = Math.min(3, Math.max(0, n - 1));
}
function update(state) {
latest = state;
updateTray(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}`;
else if (state.inbox.length) kind = `card:${state.inbox[0].id}`;
if (kind === shown) return;
shown = kind;
locked = false;
timers.forEach(clearTimeout);
timers = [];
body.classList.toggle("is-card", kind.startsWith("card:"));
if (kind === "profile") {
@@ -105,9 +134,15 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
} 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);
body.innerHTML = cardHtml(cardsById.get(state.inbox[0].id), state);
}
}
return { update, destroy: () => document.removeEventListener("keydown", onKey) };
return {
update,
destroy: () => {
timers.forEach(clearTimeout);
document.removeEventListener("keydown", onKey);
},
};
}
+19
View File
@@ -16,3 +16,22 @@ export const $ = (root, sel) => root.querySelector(sel);
// Escape text for use inside innerHTML templates.
export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
// A 12-hour dial whose single hand makes one full turn per in-game day. progress is 0..1.
export function dialSvg(className = "") {
const ticks = Array.from({ length: 12 }, (_, i) => {
const long = i % 3 === 0;
return `<line x1="50" y1="${long ? 8 : 10}" x2="50" y2="${long ? 18 : 15}" transform="rotate(${i * 30} 50 50)"/>`;
}).join("");
return `
<svg class="dial ${className}" viewBox="0 0 100 100" aria-hidden="true" focusable="false">
<circle class="dial__face" cx="50" cy="50" r="46"/>
<g class="dial__ticks">${ticks}</g>
<line class="dial__hand" x1="50" y1="54" x2="50" y2="16" transform="rotate(0 50 50)"/>
<circle class="dial__pin" cx="50" cy="50" r="4"/>
</svg>`;
}
export function setDial(svg, progress) {
svg?.querySelector(".dial__hand")?.setAttribute("transform", `rotate(${(progress * 360).toFixed(1)} 50 50)`);
}
+66
View File
@@ -0,0 +1,66 @@
// A tiny Markdown renderer for the rules. It supports exactly what the rules use: #, ##, ### headings,
// paragraphs, - and 1. lists (no nesting), **bold**, *italic*, `code`, pipe tables, --- and > quotes.
// Everything is escaped first, so a rules file can never inject markup.
import { esc } from "./desk.js";
function inline(text) {
return esc(text)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*\w])\*([^*\s][^*]*?)\*(?![*\w])/g, "$1<em>$2</em>");
}
const cells = (line) => line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
export function renderMarkdown(src) {
const lines = src.replace(/\r\n?/g, "\n").split("\n");
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
const h = /^(#{1,3})\s+(.*)$/.exec(line);
if (h) {
out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`);
i++;
continue;
}
if (/^-{3,}\s*$/.test(line)) { out.push("<hr>"); i++; continue; }
if (/^\|.*\|\s*$/.test(line) && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1] ?? "")) {
const head = cells(line);
i += 2;
const rows = [];
while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]));
out.push(
`<table><thead><tr>${head.map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead>` +
`<tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody></table>`,
);
continue;
}
if (/^>\s?/.test(line)) {
const quote = [];
while (i < lines.length && /^>\s?/.test(lines[i])) quote.push(lines[i++].replace(/^>\s?/, ""));
out.push(`<blockquote>${quote.map(inline).join(" ")}</blockquote>`);
continue;
}
const list = /^(-|\d+\.)\s+/.exec(line);
if (list) {
const tag = list[1] === "-" ? "ul" : "ol";
const re = tag === "ul" ? /^-\s+(.*)$/ : /^\d+\.\s+(.*)$/;
const items = [];
while (i < lines.length && re.test(lines[i])) items.push(re.exec(lines[i++])[1]);
out.push(`<${tag}>${items.map((x) => `<li>${inline(x)}</li>`).join("")}</${tag}>`);
continue;
}
const para = [];
while (i < lines.length && lines[i].trim() && !/^(#{1,3}\s|-{3,}\s*$|>|\||(-|\d+\.)\s)/.test(lines[i])) para.push(lines[i++]);
out.push(`<p>${inline(para.join(" "))}</p>`);
}
return out.join("\n");
}
+31 -1
View File
@@ -7,6 +7,7 @@ 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";
import { renderMarkdown } from "./markdown.js";
const ARM_MS = 4000; // how long a "Sure?" button stays armed
@@ -17,6 +18,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
let armed = null; // "action:arg" waiting for a second click
let armTimer = 0;
let exportText = "";
const rulesCache = {}; // language -> rendered html, or "error"
const say = (text, kind = "ok") => { message = { text, kind }; };
const go = (next) => { screen = next; message = null; disarm(); render(); };
@@ -85,6 +87,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
${button("load", t("menu.load"))}
${button("export", t("menu.export"))}
${button("import", t("menu.import"))}
${button("rules", t("menu.rules"))}
${button("settings", t("menu.settings"))}
${button("quit", t("menu.quit"))}
</div>`;
@@ -100,6 +103,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
${button("new", t("menu.new"))}
${button("load", t("menu.load"))}
${button("import", t("menu.import"))}
${button("rules", t("menu.rules"))}
${button("settings", t("menu.settings"))}
${button("credits", t("menu.credits"))}
</div>`;
@@ -213,6 +217,13 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
<input type="checkbox" id="setting-slow" ${s.slow ? "checked" : ""}>
<span><b>${t("settings.slow")}</b><small>${t("settings.slowHint")}</small></span>
</label>
<label class="menu__field menu__check">
<input type="checkbox" id="setting-sound" ${s.sound ? "checked" : ""}>
<span><b>${t("settings.sound")}</b><small>${t("settings.soundHint")}</small></span>
</label>
<label class="menu__field">${t("settings.volume")}
<input type="range" id="setting-volume" min="0" max="1" step="0.05" value="${s.volume}" ${s.sound ? "" : "disabled"}>
</label>
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
${back()}`;
}
@@ -226,7 +237,23 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
${back()}`;
}
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits };
// The rules are plain Markdown files (data/rules/rules.<lang>.md), fetched on first use.
function rules() {
const lang = getLang();
const body = rulesCache[lang];
if (body === undefined) {
rulesCache[lang] = null; // loading
fetch(`data/rules/rules.${lang}.md`)
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((md) => { rulesCache[lang] = renderMarkdown(md); })
.catch(() => { rulesCache[lang] = "error"; })
.finally(() => { if (screen === "rules" && !root.hidden) render(); });
}
const html = body === "error" ? `<p class="menu__msg menu__msg--error">${t("rules.error")}</p>` : body ?? `<p class="menu__sub">${t("rules.loading")}</p>`;
return `<div class="rules" tabindex="0" aria-label="${t("rules.title")}">${html}</div>${back()}`;
}
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules };
function render() {
root.innerHTML = `
@@ -296,6 +323,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
export: () => { exportText = encodeSave(getState()); go("export"); },
import: () => go("import"),
settings: () => go("settings"),
rules: () => go("rules"),
credits: () => go("credits"),
back: () => go("home"),
resume: () => handlers.onResume(),
@@ -336,6 +364,8 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
root.addEventListener("change", async (e) => {
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
if (e.target.id === "setting-sound") { handlers.onSettings({ sound: e.target.checked }); render(); }
if (e.target.id === "setting-volume") handlers.onSettings({ volume: Number(e.target.value) });
if (e.target.id === "import-file" && e.target.files[0]) {
$(root, "#import-text").value = await e.target.files[0].text();
}
+3
View File
@@ -77,6 +77,9 @@ export function createNewspaper(root, tpl) {
if (key === shown) return;
shown = key;
root.classList.remove("is-fresh");
void root.offsetWidth; // restart the fade
root.classList.add("is-fresh");
const fill = (s) => s.replaceAll("{country}", state.countryName);
const [lead, ...rest] = computeHeadlines(state, tpl);
$(root, ".news__lead h3").textContent = fill(tx(lead.h));
+56
View File
@@ -0,0 +1,56 @@
// Phone layout only: the shelf along the top of the desk. It shows the date and a small dial,
// cycles the speed, and picks which single paper is on the desk (tray, briefing, news,
// calendar, and the ledger while a budget review is open).
import { t, dateParts } from "../i18n/i18n.js";
import { dateOfDay } from "../engine/index.js";
import { $, dialSvg, setDial } from "./desk.js";
const VIEWS = ["tray", "brief", "news", "clock"];
export function createShelf(root, bal, { getView, onView, onSpeed, onMenu }) {
const speeds = [0, ...bal.time.speeds];
root.innerHTML = `
<div class="shelf__time">
${dialSvg("shelf__dial")}
<div class="shelf__date"><b></b><span></span></div>
<button class="brass shelf__speed" data-speed-cycle></button>
</div>
<div class="shelf__tabs" role="group" aria-label="${t("shelf.nav")}">
${VIEWS.map((v) => `<button class="shelf__tab" data-view="${v}"><span>${t("shelf." + v)}</span>${v === "tray" ? `<i class="shelf__badge" data-badge></i>` : ""}</button>`).join("")}
<button class="shelf__tab shelf__tab--ledger" data-view="ledger" hidden><span>${t("shelf.ledger")}</span></button>
<button class="shelf__tab shelf__tab--menu" data-menu><span>${t("shelf.menu")}</span></button>
</div>`;
const dial = $(root, ".shelf__dial");
let speed = 0;
root.addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
if (b.dataset.menu !== undefined) onMenu();
else if (b.dataset.speedCycle !== undefined) onSpeed(speeds[(speeds.indexOf(speed) + 1) % speeds.length]);
else if (b.dataset.view) onView(b.dataset.view);
});
function update(state) {
const d = dateParts(dateOfDay(state.startDate, state.day).iso);
$(root, ".shelf__date b").textContent = `${d.weekday} ${d.day} ${d.month}`;
$(root, ".shelf__date span").textContent = d.year;
speed = state.speed;
const sp = $(root, ".shelf__speed");
sp.textContent = speed === 0 ? "II" : `${speed}×`;
sp.setAttribute("aria-label", speed === 0 ? t("cal.pause") : t("cal.speed", { n: speed }));
const view = getView();
const ledger = Boolean(state.pending.review && !state.pending.profile && !state.over);
$(root, '[data-view="ledger"]').hidden = !ledger;
for (const b of root.querySelectorAll("[data-view]")) b.setAttribute("aria-pressed", String(b.dataset.view === view));
const badge = $(root, "[data-badge]");
badge.textContent = state.inbox.length || "";
badge.hidden = state.inbox.length === 0;
badge.classList.toggle("is-warn", state.inbox.length >= bal.cards.inboxMax - 2);
}
return { update, clockHand: (p) => setDial(dial, p) };
}