Merge branch 'phase/54-whats-new'
This commit is contained in:
@@ -118,3 +118,28 @@
|
|||||||
Голый `dotnet run --project src/HSchool.AppHost` по-прежнему собирает — так удобнее агентам с
|
Голый `dotnet run --project src/HSchool.AppHost` по-прежнему собирает — так удобнее агентам с
|
||||||
грязным деревом. Linux-обёртки нет: точка входа Windows — `.cmd`. Протокол, HTTP и сейв не
|
грязным деревом. Linux-обёртки нет: точка входа Windows — `.cmd`. Протокол, HTTP и сейв не
|
||||||
трогаем.
|
трогаем.
|
||||||
|
|
||||||
|
## Что нового после входа
|
||||||
|
|
||||||
|
### Зачем
|
||||||
|
|
||||||
|
Альфа обновляется часто. Игрок не смотрит git: если с прошлого захода в этот браузер накатили
|
||||||
|
другую сборку Server, один раз показать, что вошло в неё.
|
||||||
|
|
||||||
|
### Было / Стало / Почему
|
||||||
|
|
||||||
|
**Было.** Нет версии сборки на экране. Сессионная кука HttpOnly и про вход.
|
||||||
|
|
||||||
|
**Стало.** Сборка Server вшивает `HEAD` и `git log --first-parent`. `GET /api/changelog?since=`
|
||||||
|
отдаёт текущий SHA и коммиты после якоря. Клиент держит `hschool.seen-rev` (год, `Path=/`,
|
||||||
|
`SameSite=Lax`, не HttpOnly). Первый заход без куки окно не показывает — только запоминает
|
||||||
|
текущий SHA. Окно — после входа, не при возврате из школы.
|
||||||
|
|
||||||
|
**Почему.** «Накатанный» — то, что в dll, а не грязный HEAD при `--no-build`. First-parent после
|
||||||
|
`--no-ff` — это слитые фазы, не внутренняя нарезка ветки. `Mark phase N` оставляем в цепочке
|
||||||
|
якорем, в список не рисуем: иначе кука на таком HEAD потеряла бы следующее слияние.
|
||||||
|
|
||||||
|
### Что не входит
|
||||||
|
|
||||||
|
Ручной `CHANGELOG.md`. Запись на пользователе в `users.json`. Вызов git из бегущего процесса.
|
||||||
|
Бамп версии сокета. Окно внутри открытой школы.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Фаза 54. Что нового
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
Нет. Сессия (фаза 38) уже ставит куку входа; это отдельная кука и отдельное окно.
|
||||||
|
|
||||||
|
## Зачем
|
||||||
|
|
||||||
|
После наката игрок не обязан читать git. Окно один раз показывает, что изменилось с прошлого
|
||||||
|
захода в этот браузер.
|
||||||
|
|
||||||
|
## Задачи
|
||||||
|
|
||||||
|
- [x] Сборка Server вшивает SHA и `git log --first-parent` (не вызов git в рантайме)
|
||||||
|
- [x] `GET /api/changelog?since=` с сессией: текущий SHA и коммиты строго после `since`
|
||||||
|
- [x] Нет `since`, неизвестный SHA, `since` = текущий — `commits` пустой
|
||||||
|
- [x] Subject `Mark phase N…` в окне нет; в цепочке якоря они остаются
|
||||||
|
- [x] Кука `hschool.seen-rev` (не HttpOnly, не сессионная): первый заход молча запоминает HEAD
|
||||||
|
- [x] Модалка только после `ensureSession` (вход и повторный вход после logout), не с меню школы
|
||||||
|
- [x] [`protocol.md`](../protocol.md) HTTP, версию сокета не бампить
|
||||||
|
|
||||||
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
|
- [x] Срез после известного SHA — видимые коммиты старше→новее, без `Mark phase`
|
||||||
|
- [x] Якорь на `Mark phase` — следующие видимые всё равно отдаются
|
||||||
|
- [x] Нет `since` / неизвестный SHA / `since` = текущий — пустой список
|
||||||
|
- [x] `GET /api/changelog` без сессии — `401`
|
||||||
|
- [x] С сессией без `since` — 40 hex в `current`, `commits` пустой
|
||||||
|
- [x] Нет куки — запрос без `since`, кука = `current`, диалога нет
|
||||||
|
- [x] Кука старше, сервер вернул коммиты — диалог, по закрытию кука = `current`
|
||||||
|
|
||||||
|
## Критерий готовности
|
||||||
|
|
||||||
|
- Первый заход после деплоя с новой сборкой Server показывает окно; повторный заход в том же
|
||||||
|
браузере — нет
|
||||||
|
- `run-aspire` без `--rebuild` после коммита только в доках может показать старый SHA: вшито
|
||||||
|
на сборке, не HEAD рабочего дерева
|
||||||
|
|
||||||
|
## Стоп
|
||||||
|
|
||||||
|
Не писать last-seen в `users.json`. Не бампить сокет. Не вызывать git из бегущего сервера.
|
||||||
|
Не показывать окно при возврате из школы на меню.
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
|
|
||||||
Склейка уже живых систем и мелкий DX, не новый кусок игры. Номера не спорят со срезом 10.
|
Склейка уже живых систем и мелкий DX, не новый кусок игры. Номера не спорят со срезом 10.
|
||||||
После каждой игровой фазы школа целая. 50–52 стоят на 48; 53 — на 18 и 32, можно параллельно
|
После каждой игровой фазы школа целая. 50–52 стоят на 48; 53 — на 18 и 32, можно параллельно
|
||||||
с 48. 49 от них не зависит. Баг в уже сделанном — [`../bugs/README.md`](../bugs/README.md).
|
с 48. 49 и 54 от них не зависят. Баг в уже сделанном — [`../bugs/README.md`](../bugs/README.md).
|
||||||
|
|
||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -241,4 +241,4 @@
|
|||||||
| [51. Тепло на уроке](51-warmth-lesson.md) | ✅ | Замёрзший учится хуже, как голодный |
|
| [51. Тепло на уроке](51-warmth-lesson.md) | ✅ | Замёрзший учится хуже, как голодный |
|
||||||
| [52. Учебник на уроке](52-textbook-lesson.md) | ✅ | Нет в сумке — половинный рост |
|
| [52. Учебник на уроке](52-textbook-lesson.md) | ✅ | Нет в сумке — половинный рост |
|
||||||
| [53. Погода на дороге](53-weather-commute.md) | ✅ | Снег и дождь добавляют минуты к приходу |
|
| [53. Погода на дороге](53-weather-commute.md) | ✅ | Снег и дождь добавляют минуты к приходу |
|
||||||
| [54. Что нового](54-whats-new.md) | 🔄 | После входа — окно коммитов с прошлого визита |
|
| [54. Что нового](54-whats-new.md) | ✅ | После входа — окно коммитов с прошлого визита |
|
||||||
|
|||||||
@@ -68,6 +68,26 @@ closes the connection with a policy violation and never sends Welcome. A second
|
|||||||
that already has a live connection is closed the same way; `POST /api/session` for that name
|
that already has a live connection is closed the same way; `POST /api/session` for that name
|
||||||
returns `409` `name-online`. Hello is unchanged (version + locale only).
|
returns `409` `name-online`. Hello is unchanged (version + locale only).
|
||||||
|
|
||||||
|
### `GET /api/changelog`
|
||||||
|
|
||||||
|
What landed in the running Server build since an earlier commit. Requires a session cookie.
|
||||||
|
`current` is the SHA baked at compile time (`git rev-parse HEAD` during that build), not the live
|
||||||
|
working tree. `commits` is `git log --first-parent` after `since`, oldest first, subjects that
|
||||||
|
match `Mark phase <digits>` omitted. Without `since`, with an unknown SHA, or when `since` equals
|
||||||
|
`current`, `commits` is empty — a first visit must not dump the whole history.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"current": "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
"commits": [
|
||||||
|
{ "sha": "89abcdef0123456789abcdef0123456789abcdef", "date": "2026-08-20T08:21:00+00:00", "subject": "Merge branch 'phase/53-weather-commute'" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional `?since=` is a 40-character hex SHA from the client's `hschool.seen-rev` cookie. That
|
||||||
|
cookie is not HttpOnly and is not the session cookie. Protocol version is unchanged.
|
||||||
|
|
||||||
### `GET /api/schools`
|
### `GET /api/schools`
|
||||||
|
|
||||||
Everything the main menu needs in one request. `schoolWeekDays` is 5–7 working days counted
|
Everything the main menu needs in one request. `schoolWeekDays` is 5–7 working days counted
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const ru = {
|
|||||||
sessionInvalidName: 'Имя должно быть от 1 до 40 символов.',
|
sessionInvalidName: 'Имя должно быть от 1 до 40 символов.',
|
||||||
sessionFailed: 'Не удалось войти. Попробуйте ещё раз.',
|
sessionFailed: 'Не удалось войти. Попробуйте ещё раз.',
|
||||||
sessionLogout: 'Выйти',
|
sessionLogout: 'Выйти',
|
||||||
|
changelogTitle: 'Что нового',
|
||||||
|
changelogOk: 'Понятно',
|
||||||
|
|
||||||
schoolsTitle: 'Школы',
|
schoolsTitle: 'Школы',
|
||||||
schoolsMine: 'Мои',
|
schoolsMine: 'Мои',
|
||||||
@@ -392,6 +394,8 @@ const en: Messages = {
|
|||||||
sessionInvalidName: 'The name must be 1–40 characters.',
|
sessionInvalidName: 'The name must be 1–40 characters.',
|
||||||
sessionFailed: 'Could not sign in. Try again.',
|
sessionFailed: 'Could not sign in. Try again.',
|
||||||
sessionLogout: 'Sign out',
|
sessionLogout: 'Sign out',
|
||||||
|
changelogTitle: "What's new",
|
||||||
|
changelogOk: 'OK',
|
||||||
|
|
||||||
schoolsTitle: 'Schools',
|
schoolsTitle: 'Schools',
|
||||||
schoolsMine: 'Mine',
|
schoolsMine: 'Mine',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { GameScreen } from './ui/gameScreen.ts';
|
|||||||
import { localeSwitch } from './ui/localeSwitch.ts';
|
import { localeSwitch } from './ui/localeSwitch.ts';
|
||||||
import { MainMenu } from './ui/mainMenu.ts';
|
import { MainMenu } from './ui/mainMenu.ts';
|
||||||
import { ensureSession } from './ui/sessionGate.ts';
|
import { ensureSession } from './ui/sessionGate.ts';
|
||||||
|
import { showWhatsNew } from './ui/whatsNew.ts';
|
||||||
import type { School } from './net/api.ts';
|
import type { School } from './net/api.ts';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
footer.prepend(localeSwitch());
|
footer.prepend(localeSwitch());
|
||||||
|
|
||||||
await ensureSession();
|
await ensureSession();
|
||||||
|
await showWhatsNew();
|
||||||
|
|
||||||
let openSchool: School | null = null;
|
let openSchool: School | null = null;
|
||||||
let connectionStatus: ConnectionStatus = 'connecting';
|
let connectionStatus: ConnectionStatus = 'connecting';
|
||||||
@@ -109,6 +111,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
menu.stop();
|
menu.stop();
|
||||||
await logoutSession();
|
await logoutSession();
|
||||||
await ensureSession();
|
await ensureSession();
|
||||||
|
await showWhatsNew();
|
||||||
connection.connect();
|
connection.connect();
|
||||||
showMenu();
|
showMenu();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -476,6 +476,24 @@ export async function logoutSession(): Promise<void> {
|
|||||||
await request<void>('/api/session', { method: 'DELETE' }, { expectBody: false });
|
await request<void>('/api/session', { method: 'DELETE' }, { expectBody: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChangelogCommit {
|
||||||
|
readonly sha: string;
|
||||||
|
readonly date: string;
|
||||||
|
readonly subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangelogResponse {
|
||||||
|
readonly current: string;
|
||||||
|
readonly commits: readonly ChangelogCommit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchChangelog(since?: string | null): Promise<ChangelogResponse> {
|
||||||
|
const query = since !== null && since !== undefined && since.length > 0
|
||||||
|
? `?since=${encodeURIComponent(since)}`
|
||||||
|
: '';
|
||||||
|
return request<ChangelogResponse>(`/api/changelog${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
export interface SwarmUiKindPreset {
|
export interface SwarmUiKindPreset {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
|||||||
@@ -1425,6 +1425,39 @@ body {
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog--changelog {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog__list {
|
||||||
|
max-height: min(50dvh, 360px);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog__item {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog__item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog__date {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog__subject {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { SEEN_REVISION_COOKIE, readSeenRevision, writeSeenRevision } from './seenRevision.ts';
|
||||||
|
|
||||||
|
const current = '0123456789abcdef0123456789abcdef01234567';
|
||||||
|
|
||||||
|
describe('seen revision cookie', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.cookie = `${SEEN_REVISION_COOKIE}=; Path=/; Max-Age=0`;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a 40-character SHA', () => {
|
||||||
|
writeSeenRevision(current);
|
||||||
|
expect(readSeenRevision()).toBe(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a value that is not a SHA', () => {
|
||||||
|
writeSeenRevision('not-a-sha');
|
||||||
|
expect(readSeenRevision()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/** Last Server build SHA this browser already showed. Not the HttpOnly session cookie. */
|
||||||
|
|
||||||
|
export const SEEN_REVISION_COOKIE = 'hschool.seen-rev';
|
||||||
|
|
||||||
|
const SHA = /^[0-9a-f]{40}$/i;
|
||||||
|
const MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
export function readSeenRevision(): string | null {
|
||||||
|
const prefix = `${SEEN_REVISION_COOKIE}=`;
|
||||||
|
const parts = document.cookie.split(';');
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (!trimmed.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = decodeURIComponent(trimmed.slice(prefix.length));
|
||||||
|
return SHA.test(value) ? value.toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSeenRevision(sha: string): void {
|
||||||
|
if (!SHA.test(sha)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pieces = [
|
||||||
|
`${SEEN_REVISION_COOKIE}=${encodeURIComponent(sha.toLowerCase())}`,
|
||||||
|
'Path=/',
|
||||||
|
'SameSite=Lax',
|
||||||
|
`Max-Age=${MAX_AGE_SECONDS}`,
|
||||||
|
];
|
||||||
|
if (globalThis.location?.protocol === 'https:') {
|
||||||
|
pieces.push('Secure');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.cookie = pieces.join('; ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fetchChangelog } from '../net/api.ts';
|
||||||
|
import { t } from '../i18n/strings.ts';
|
||||||
|
import { SEEN_REVISION_COOKIE, readSeenRevision, writeSeenRevision } from './seenRevision.ts';
|
||||||
|
import { showWhatsNew } from './whatsNew.ts';
|
||||||
|
|
||||||
|
vi.mock('../net/api.ts', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../net/api.ts')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
fetchChangelog: vi.fn(),
|
||||||
|
fetchSession: vi.fn(),
|
||||||
|
fetchSchools: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const current = '0123456789abcdef0123456789abcdef01234567';
|
||||||
|
const previous = '89abcdef0123456789abcdef0123456789abcdef';
|
||||||
|
|
||||||
|
describe('whats-new after login', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
document.cookie = `${SEEN_REVISION_COOKIE}=; Path=/; Max-Age=0`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.querySelector('dialog')?.remove();
|
||||||
|
document.cookie = `${SEEN_REVISION_COOKIE}=; Path=/; Max-Age=0`;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores current without a dialog when there is no cookie', async () => {
|
||||||
|
vi.mocked(fetchChangelog).mockResolvedValue({ current, commits: [] });
|
||||||
|
|
||||||
|
await showWhatsNew();
|
||||||
|
|
||||||
|
expect(fetchChangelog).toHaveBeenCalledWith(null);
|
||||||
|
expect(readSeenRevision()).toBe(current);
|
||||||
|
expect(document.querySelector('dialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not open a dialog when the cookie already matches current', async () => {
|
||||||
|
writeSeenRevision(current);
|
||||||
|
vi.mocked(fetchChangelog).mockResolvedValue({ current, commits: [] });
|
||||||
|
|
||||||
|
await showWhatsNew();
|
||||||
|
|
||||||
|
expect(fetchChangelog).toHaveBeenCalledWith(current);
|
||||||
|
expect(document.querySelector('dialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the dialog for unseen commits and writes current on dismiss', async () => {
|
||||||
|
writeSeenRevision(previous);
|
||||||
|
vi.mocked(fetchChangelog).mockResolvedValue({
|
||||||
|
current,
|
||||||
|
commits: [
|
||||||
|
{
|
||||||
|
sha: current,
|
||||||
|
date: '2026-08-20T08:21:00+00:00',
|
||||||
|
subject: "Merge branch 'phase/53-weather-commute'",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = showWhatsNew();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const dialog = document.querySelector('dialog');
|
||||||
|
expect(dialog?.textContent).toContain(t('changelogTitle'));
|
||||||
|
expect(dialog?.textContent).toContain("Merge branch 'phase/53-weather-commute'");
|
||||||
|
|
||||||
|
const ok = dialog?.querySelector('button');
|
||||||
|
ok?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(readSeenRevision()).toBe(current);
|
||||||
|
expect(document.querySelector('dialog')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { fetchChangelog, type ChangelogCommit } from '../net/api.ts';
|
||||||
|
import { intlTag } from '../i18n/locale.ts';
|
||||||
|
import { t } from '../i18n/strings.ts';
|
||||||
|
import { el } from './dom.ts';
|
||||||
|
import { Modal } from './modal.ts';
|
||||||
|
import { readSeenRevision, writeSeenRevision } from './seenRevision.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After login: first visit stores the baked SHA; a newer build opens the changelog once.
|
||||||
|
* Returning from a school to the menu does not call this.
|
||||||
|
*/
|
||||||
|
export async function showWhatsNew(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const changelog = await fetchChangelog(readSeenRevision());
|
||||||
|
if (!/^[0-9a-f]{40}$/i.test(changelog.current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changelog.commits.length > 0) {
|
||||||
|
await whatsNewDialog(changelog.commits);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeSeenRevision(changelog.current);
|
||||||
|
} catch {
|
||||||
|
// Menu still works if the host skipped the embed or the request failed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function whatsNewDialog(commits: readonly ChangelogCommit[]): Promise<void> {
|
||||||
|
const modal = new Modal(undefined);
|
||||||
|
const ok = el('button', {
|
||||||
|
class: 'button button--primary',
|
||||||
|
type: 'button',
|
||||||
|
text: t('changelogOk'),
|
||||||
|
onClick: () => modal.close(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = commits.map((commit) => {
|
||||||
|
const item = el('li', { class: 'changelog__item' });
|
||||||
|
const date = formatCommitDate(commit.date);
|
||||||
|
if (date !== null) {
|
||||||
|
item.append(el('time', { class: 'changelog__date', text: date }));
|
||||||
|
}
|
||||||
|
|
||||||
|
item.append(el('span', { class: 'changelog__subject', text: commit.subject }));
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.element.classList.add('dialog--changelog');
|
||||||
|
modal.element.append(
|
||||||
|
el('h2', { class: 'dialog__title', text: t('changelogTitle') }),
|
||||||
|
el('ul', { class: 'changelog__list' }, ...items),
|
||||||
|
el('div', { class: 'dialog__actions' }, ok),
|
||||||
|
);
|
||||||
|
|
||||||
|
return modal.open(ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommitDate(iso: string): string | null {
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(intlTag(), {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using HSchool.Server.Changelog;
|
||||||
|
|
||||||
|
namespace HSchool.Server.Api;
|
||||||
|
|
||||||
|
internal static class ChangelogEndpoints
|
||||||
|
{
|
||||||
|
public static void MapChangelogEndpoints(this IEndpointRouteBuilder builder)
|
||||||
|
{
|
||||||
|
builder.MapGet("/api/changelog", GetAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult GetAsync(string? since, BuildChangelog changelog)
|
||||||
|
{
|
||||||
|
var commits = changelog.VisibleSince(since)
|
||||||
|
.Select(commit => new ChangelogCommitResponse(
|
||||||
|
commit.Sha,
|
||||||
|
commit.Date.ToUniversalTime(),
|
||||||
|
commit.Subject))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return Results.Json(new ChangelogResponse(changelog.CurrentSha, commits));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ChangelogResponse(string Current, IReadOnlyList<ChangelogCommitResponse> Commits);
|
||||||
|
|
||||||
|
private sealed record ChangelogCommitResponse(string Sha, DateTimeOffset Date, string Subject);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace HSchool.Server.Changelog;
|
||||||
|
|
||||||
|
internal sealed record ChangelogCommit(string Sha, DateTimeOffset Date, string Subject);
|
||||||
|
|
||||||
|
/// <summary>First-parent git log baked into the Server assembly at compile time.</summary>
|
||||||
|
internal sealed partial class BuildChangelog
|
||||||
|
{
|
||||||
|
internal const string ResourceName = "HSchool.Server.changelog.tsv";
|
||||||
|
internal const int MaxVisibleCommits = 100;
|
||||||
|
|
||||||
|
private static readonly UTF8Encoding Utf8 = new(encoderShouldEmitUTF8Identifier: false);
|
||||||
|
|
||||||
|
public BuildChangelog(string currentSha, IReadOnlyList<ChangelogCommit> commits)
|
||||||
|
{
|
||||||
|
CurrentSha = currentSha;
|
||||||
|
Commits = commits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CurrentSha { get; }
|
||||||
|
|
||||||
|
/// <summary>Newest first, including <c>Mark phase</c> commits used as slice anchors.</summary>
|
||||||
|
public IReadOnlyList<ChangelogCommit> Commits { get; }
|
||||||
|
|
||||||
|
public static BuildChangelog LoadEmbedded()
|
||||||
|
{
|
||||||
|
var assembly = typeof(BuildChangelog).Assembly;
|
||||||
|
using var stream = assembly.GetManifestResourceStream(ResourceName);
|
||||||
|
if (stream is null)
|
||||||
|
{
|
||||||
|
return new BuildChangelog("", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream, Utf8);
|
||||||
|
return Parse(reader.ReadToEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BuildChangelog Parse(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return new BuildChangelog("", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (lines.Length == 0)
|
||||||
|
{
|
||||||
|
return new BuildChangelog("", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = IsSha(lines[0]) ? lines[0].ToLowerInvariant() : "";
|
||||||
|
var commits = new List<ChangelogCommit>();
|
||||||
|
foreach (var line in lines.Skip(1))
|
||||||
|
{
|
||||||
|
if (TryParseCommit(line, out var commit))
|
||||||
|
{
|
||||||
|
commits.Add(commit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BuildChangelog(current, commits);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ChangelogCommit> VisibleSince(string? since)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(since)
|
||||||
|
|| string.Equals(since, CurrentSha, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var newer = new List<ChangelogCommit>();
|
||||||
|
var found = false;
|
||||||
|
foreach (var commit in Commits)
|
||||||
|
{
|
||||||
|
if (string.Equals(commit.Sha, since, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
newer.Add(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
newer.Reverse();
|
||||||
|
var visible = new List<ChangelogCommit>();
|
||||||
|
foreach (var commit in newer)
|
||||||
|
{
|
||||||
|
if (IsMarkPhase(commit.Subject))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.Add(commit);
|
||||||
|
if (visible.Count >= MaxVisibleCommits)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsMarkPhase(string subject) => MarkPhasePattern().IsMatch(subject);
|
||||||
|
|
||||||
|
private static bool TryParseCommit(string line, out ChangelogCommit commit)
|
||||||
|
{
|
||||||
|
commit = null!;
|
||||||
|
var parts = line.Split('\x1f');
|
||||||
|
if (parts.Length < 3 || !IsSha(parts[0]))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DateTimeOffset.TryParse(parts[1], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var subject = string.Join('\x1f', parts.Skip(2)).Trim();
|
||||||
|
if (subject.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
commit = new ChangelogCommit(parts[0].ToLowerInvariant(), date, subject);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSha(string value) => ShaPattern().IsMatch(value);
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^[0-9a-fA-F]{40}$", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex ShaPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^Mark phase \d+", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex MarkPhasePattern();
|
||||||
|
}
|
||||||
@@ -32,4 +32,26 @@
|
|||||||
<InternalsVisibleTo Include="HSchool.Server.Tests" />
|
<InternalsVisibleTo Include="HSchool.Server.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<HSchoolRepoRoot>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..'))</HSchoolRepoRoot>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Bake git HEAD + first-parent log; the running process does not call git. -->
|
||||||
|
<Target Name="EmbedGitChangelog" BeforeTargets="CreateManifestResourceNames" Condition="'$(DesignTimeBuild)' != 'true'">
|
||||||
|
<MakeDir Directories="$(IntermediateOutputPath)" />
|
||||||
|
<PropertyGroup>
|
||||||
|
<_ChangelogTsv>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\$(IntermediateOutputPath)changelog.tsv'))</_ChangelogTsv>
|
||||||
|
</PropertyGroup>
|
||||||
|
<Exec Condition="$([MSBuild]::IsOSPlatform('Windows'))" Command="powershell -NoProfile -ExecutionPolicy Bypass -File "$(HSchoolRepoRoot)\tools\embed-changelog.ps1" -RepoRoot "$(HSchoolRepoRoot)" -Output "$(_ChangelogTsv)"" />
|
||||||
|
<Exec Condition="!$([MSBuild]::IsOSPlatform('Windows'))" Command="sh "$(HSchoolRepoRoot)/tools/embed-changelog.sh" "$(HSchoolRepoRoot)" "$(_ChangelogTsv)"" />
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="$(_ChangelogTsv)" Condition="Exists('$(_ChangelogTsv)')">
|
||||||
|
<LogicalName>HSchool.Server.changelog.tsv</LogicalName>
|
||||||
|
<Type>Non-Resx</Type>
|
||||||
|
<WithCulture>false</WithCulture>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<FileWrites Include="$(_ChangelogTsv)" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using HSchool.Server;
|
using HSchool.Server;
|
||||||
using HSchool.Server.Api;
|
using HSchool.Server.Api;
|
||||||
|
using HSchool.Server.Changelog;
|
||||||
using HSchool.Server.Game;
|
using HSchool.Server.Game;
|
||||||
using HSchool.Server.Net;
|
using HSchool.Server.Net;
|
||||||
using HSchool.Server.Session;
|
using HSchool.Server.Session;
|
||||||
@@ -42,6 +43,7 @@ builder.Services.AddSingleton<GameCommandQueue>();
|
|||||||
builder.Services.AddSingleton<ClientRegistry>();
|
builder.Services.AddSingleton<ClientRegistry>();
|
||||||
builder.Services.AddSingleton<UserStore>();
|
builder.Services.AddSingleton<UserStore>();
|
||||||
builder.Services.AddSingleton<SessionService>();
|
builder.Services.AddSingleton<SessionService>();
|
||||||
|
builder.Services.AddSingleton(_ => BuildChangelog.LoadEmbedded());
|
||||||
builder.Services.AddSingleton<GameMetrics>();
|
builder.Services.AddSingleton<GameMetrics>();
|
||||||
builder.Services.AddSingleton<SchoolStore>();
|
builder.Services.AddSingleton<SchoolStore>();
|
||||||
builder.Services.AddSingleton<ModContent>();
|
builder.Services.AddSingleton<ModContent>();
|
||||||
@@ -79,6 +81,7 @@ app.UseWebSockets(new WebSocketOptions
|
|||||||
app.UseMiddleware<SessionAuthMiddleware>();
|
app.UseMiddleware<SessionAuthMiddleware>();
|
||||||
|
|
||||||
app.MapSessionEndpoints();
|
app.MapSessionEndpoints();
|
||||||
|
app.MapChangelogEndpoints();
|
||||||
app.MapSchoolEndpoints();
|
app.MapSchoolEndpoints();
|
||||||
app.MapSettingsEndpoints();
|
app.MapSettingsEndpoints();
|
||||||
app.MapTimetableEndpoints();
|
app.MapTimetableEndpoints();
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace HSchool.AppHost.Tests;
|
||||||
|
|
||||||
|
[Collection(AppHostCollection.Name)]
|
||||||
|
public class ChangelogApiTests(AppHostFixture fixture)
|
||||||
|
{
|
||||||
|
private static readonly Regex Sha = new("^[0-9a-f]{40}$", RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Changelog_WithoutSession_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var http = fixture.App.GetEndpoint("server", "http").ToString();
|
||||||
|
using var client = new HttpClient { BaseAddress = new Uri(http) };
|
||||||
|
|
||||||
|
using var response = await client.GetAsync("/api/changelog", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Changelog_WithoutSince_ReturnsCurrentAndEmptyCommits()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.LoginAsync(client);
|
||||||
|
|
||||||
|
var body = await client.GetFromJsonAsync<ChangelogDto>(
|
||||||
|
"/api/changelog",
|
||||||
|
JsonOptions,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.Matches(Sha, body.Current);
|
||||||
|
Assert.Empty(body.Commits);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Changelog_SinceCurrent_ReturnsEmptyCommits()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.LoginAsync(client);
|
||||||
|
|
||||||
|
var first = await client.GetFromJsonAsync<ChangelogDto>(
|
||||||
|
"/api/changelog",
|
||||||
|
JsonOptions,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(first);
|
||||||
|
|
||||||
|
var again = await client.GetFromJsonAsync<ChangelogDto>(
|
||||||
|
$"/api/changelog?since={first.Current}",
|
||||||
|
JsonOptions,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(again);
|
||||||
|
Assert.Equal(first.Current, again.Current);
|
||||||
|
Assert.Empty(again.Commits);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonSerializerOptions JsonOptions { get; } = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed record ChangelogDto(string Current, ChangelogCommitDto[] Commits);
|
||||||
|
|
||||||
|
private sealed record ChangelogCommitDto(string Sha, DateTimeOffset Date, string Subject);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using HSchool.Server.Changelog;
|
||||||
|
|
||||||
|
namespace HSchool.Server.Tests;
|
||||||
|
|
||||||
|
public class BuildChangelogTests
|
||||||
|
{
|
||||||
|
private const string Head = "cccccccccccccccccccccccccccccccccccccccc";
|
||||||
|
private const string Mark = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||||
|
private const string Merge = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||||
|
private const string Unknown = "dddddddddddddddddddddddddddddddddddddddd";
|
||||||
|
|
||||||
|
private static readonly string Sample = string.Join('\n',
|
||||||
|
Head,
|
||||||
|
$"{Head}\u001f2026-01-03T12:00:00Z\u001fAdd changelog window",
|
||||||
|
$"{Mark}\u001f2026-01-02T12:00:00Z\u001fMark phase 54 in progress.",
|
||||||
|
$"{Merge}\u001f2026-01-01T12:00:00Z\u001fMerge branch 'phase/53-weather-commute'");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VisibleSince_KnownSha_ReturnsOlderToNewerWithoutMarkPhase()
|
||||||
|
{
|
||||||
|
var changelog = BuildChangelog.Parse(Sample);
|
||||||
|
|
||||||
|
var visible = changelog.VisibleSince(Merge);
|
||||||
|
|
||||||
|
var commit = Assert.Single(visible);
|
||||||
|
Assert.Equal(Head, commit.Sha);
|
||||||
|
Assert.Equal("Add changelog window", commit.Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VisibleSince_MarkPhaseAnchor_StillReturnsLaterVisibleCommits()
|
||||||
|
{
|
||||||
|
var changelog = BuildChangelog.Parse(Sample);
|
||||||
|
|
||||||
|
var visible = changelog.VisibleSince(Mark);
|
||||||
|
|
||||||
|
var commit = Assert.Single(visible);
|
||||||
|
Assert.Equal("Add changelog window", commit.Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VisibleSince_MissingUnknownOrCurrent_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var changelog = BuildChangelog.Parse(Sample);
|
||||||
|
|
||||||
|
Assert.Empty(changelog.VisibleSince(null));
|
||||||
|
Assert.Empty(changelog.VisibleSince(""));
|
||||||
|
Assert.Empty(changelog.VisibleSince(Unknown));
|
||||||
|
Assert.Empty(changelog.VisibleSince(Head));
|
||||||
|
Assert.Empty(changelog.VisibleSince(Head.ToUpperInvariant()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsMarkPhase_MatchesStatusLineSubjects()
|
||||||
|
{
|
||||||
|
Assert.True(BuildChangelog.IsMarkPhase("Mark phase 54 in progress."));
|
||||||
|
Assert.True(BuildChangelog.IsMarkPhase("Mark phase 47 romance pack as complete."));
|
||||||
|
Assert.False(BuildChangelog.IsMarkPhase("Merge branch 'phase/54-whats-new'"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# UTF-8 first-parent log for HSchool.Server. Keep in sync with embed-changelog.sh.
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string] $RepoRoot,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string] $Output
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$dir = Split-Path -Parent $Output
|
||||||
|
if (-not (Test-Path -LiteralPath $dir)) {
|
||||||
|
New-Item -ItemType Directory -Path $dir | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$sha = ""
|
||||||
|
$log = ""
|
||||||
|
Push-Location -LiteralPath $RepoRoot
|
||||||
|
try {
|
||||||
|
$sha = (git rev-parse HEAD 2>$null | Out-String).Trim()
|
||||||
|
if ($LASTEXITCODE -ne 0 -or $sha.Length -ne 40) {
|
||||||
|
$sha = ""
|
||||||
|
}
|
||||||
|
elseif ($sha.Length -eq 40) {
|
||||||
|
$log = git -c core.quotepath=false log --first-parent --encoding=UTF-8 --pretty=format:%H%x1f%cI%x1f%s
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
$log = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $sha + "`n"
|
||||||
|
if (-not [string]::IsNullOrEmpty($log)) {
|
||||||
|
$text += $log.TrimEnd() + "`n"
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.IO.File]::WriteAllText($Output, $text, [System.Text.UTF8Encoding]::new($false))
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# UTF-8 first-parent log for HSchool.Server. Keep in sync with embed-changelog.ps1.
|
||||||
|
set -e
|
||||||
|
RepoRoot=$1
|
||||||
|
Output=$2
|
||||||
|
if [ -z "$RepoRoot" ] || [ -z "$Output" ]; then
|
||||||
|
echo "usage: embed-changelog.sh <repo-root> <output>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "$Output")"
|
||||||
|
sha=
|
||||||
|
log=
|
||||||
|
if sha=$(git -C "$RepoRoot" rev-parse HEAD 2>/dev/null) && [ "${#sha}" -eq 40 ]; then
|
||||||
|
log=$(git -C "$RepoRoot" -c core.quotepath=false log --first-parent --encoding=UTF-8 --pretty=format:%H%x1f%cI%x1f%s) || log=
|
||||||
|
else
|
||||||
|
sha=
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
printf '%s\n' "$sha"
|
||||||
|
if [ -n "$log" ]; then
|
||||||
|
printf '%s\n' "$log"
|
||||||
|
fi
|
||||||
|
} > "$Output"
|
||||||
Reference in New Issue
Block a user