v0.16.2 PGN site, web client panel update
This commit is contained in:
1 parent
0461020c46
commit
a012d07cd8
12 files changed
+84
-45
No files matched your search
@@ -52,8 +52,9 @@ type PGN struct {
|
||||
|
||||
// BuildPGN exports a stored game. ply < 0 exports every move; otherwise the
|
||||
// first ply moves, with result "*" and no termination when that stops short
|
||||
// of the end of the line.
|
||||
func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (*PGN, error) {
|
||||
// of the end of the line. site is the Site tag of a game played on this
|
||||
// server (PGN's "?" when empty); an imported game keeps its own.
|
||||
func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int, site string) (*PGN, error) {
|
||||
if ply > len(moves) {
|
||||
return nil, fmt.Errorf("%w: game has %d plies", ErrPlyOutOfRange, len(moves))
|
||||
}
|
||||
@@ -75,7 +76,7 @@ func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (
|
||||
}
|
||||
}
|
||||
start := record.StartTimeUTC.UTC()
|
||||
event, site, round := "Casual game", "", ""
|
||||
event, round := "Casual game", ""
|
||||
if record.ImportedBy != "" {
|
||||
event, site, round = record.Event, record.Site, record.Round
|
||||
if event == "" {
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestBuildPGN(t *testing.T) {
|
||||
rec := record(chess.StartFEN, "white_wins", "resignation")
|
||||
rec.BlackType, rec.BlackLevel, rec.WhiteType, rec.WhiteName = 2, 4, 1, "alice"
|
||||
|
||||
pgn, err := BuildPGN(rec, moves, -1)
|
||||
pgn, err := BuildPGN(rec, moves, -1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -143,25 +143,45 @@ func TestBuildPGN(t *testing.T) {
|
||||
t.Errorf("filename %q", pgn.Filename)
|
||||
}
|
||||
|
||||
partial, err := BuildPGN(rec, moves, 1)
|
||||
partial, err := BuildPGN(rec, moves, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(partial.Text, "\n\n1. e4 *\n") || partial.Filename != "chess-20260928-68007fd1-ply1.pgn" {
|
||||
t.Errorf("partial %q:\n%s", partial.Filename, partial.Text)
|
||||
}
|
||||
if _, err := BuildPGN(rec, moves, 3); err == nil {
|
||||
if _, err := BuildPGN(rec, moves, 3, ""); err == nil {
|
||||
t.Error("ply beyond the line accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// A game played here names the server's site; an imported game keeps its own.
|
||||
func TestBuildPGNSite(t *testing.T) {
|
||||
moves := stored(t, chess.StartFEN, "e2e4")
|
||||
rec := record(chess.StartFEN, "", "")
|
||||
for _, tc := range []struct{ importedBy, recordSite, site, want string }{
|
||||
{"", "", "lixen.com", "lixen.com"},
|
||||
{"", "", "", "?"},
|
||||
{"alice", "Linares ESP", "lixen.com", "Linares ESP"},
|
||||
} {
|
||||
rec.ImportedBy, rec.Site = tc.importedBy, tc.recordSite
|
||||
pgn, err := BuildPGN(rec, moves, -1, tc.site)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "[Site \"" + tc.want + "\"]\n"; !strings.Contains(pgn.Text, want) {
|
||||
t.Errorf("site %q, imported by %q: PGN lacks %q:\n%s", tc.site, tc.importedBy, want, pgn.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A resignation taken back by an undo is noted before the live result.
|
||||
func TestBuildPGNNotesContinuedConcession(t *testing.T) {
|
||||
moves := stored(t, chess.StartFEN, "e2e4", "e7e5", "d2d4")
|
||||
rec := record(chess.StartFEN, "", "")
|
||||
rec.ConcessionResult, rec.ConcessionTermination, rec.ConcessionPly = "black_wins", "resignation", 2
|
||||
|
||||
pgn, err := BuildPGN(rec, moves, -1)
|
||||
pgn, err := BuildPGN(rec, moves, -1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -171,7 +191,7 @@ func TestBuildPGNNotesContinuedConcession(t *testing.T) {
|
||||
|
||||
// The concession that is still the result needs no note.
|
||||
rec.Result, rec.Termination, rec.ConcessionPly = "black_wins", "resignation", 3
|
||||
if pgn, _ = BuildPGN(rec, moves, -1); strings.Contains(pgn.Text, "continued") {
|
||||
if pgn, _ = BuildPGN(rec, moves, -1, ""); strings.Contains(pgn.Text, "continued") {
|
||||
t.Errorf("standing resignation noted as continued:\n%s", pgn.Text)
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func (s *Service) GetGamePGN(gameID string, ply int) (*replay.PGN, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pgn, err := replay.BuildPGN(record, moves, ply)
|
||||
pgn, err := replay.BuildPGN(record, moves, ply, s.site)
|
||||
if errors.Is(err, replay.ErrNotation) {
|
||||
slog.Warn("stored game cannot be exported as PGN", "game_id", gameID, "error", err)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ type Service struct {
|
||||
integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only
|
||||
|
||||
mail *mailer // password recovery; nil when no transport is configured
|
||||
site string // Site tag of exported games played here; empty is PGN's "?"
|
||||
}
|
||||
|
||||
// New creates a service with optional storage. jwtSecret signs session tokens
|
||||
@@ -93,6 +94,12 @@ func (s *Service) SetAnonymousGameTTL(ttl time.Duration) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetSite names this server in the Site tag of the games it exports, from
|
||||
// the web client's public address; call it before serving.
|
||||
func (s *Service) SetSite(site string) {
|
||||
s.site = site
|
||||
}
|
||||
|
||||
// SetMaxUsers caps the number of accounts public registration may create;
|
||||
// zero removes the cap. Accounts created through the CLI are not limited.
|
||||
func (s *Service) SetMaxUsers(limit int) {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
/* Chess web client. Dark theme matching lixen.com. The board colours and
|
||||
piece glyph treatment are fixed (see pieceMap in app.js); everything else is
|
||||
open. Styles live here: the host's CSP has style-src 'unsafe-inline', but
|
||||
state is expressed through classes and data attributes, never inline style.
|
||||
|
||||
Two layouts share the markup: board beside the panel from 979px up, and
|
||||
board over the panel below (the host's embed frame switches its height at
|
||||
the same width). Both must fit the host's frame: 30rem tall side by side,
|
||||
46rem (capped at 80svh) stacked. */
|
||||
open. State is classes and data attributes, never inline style. Two layouts
|
||||
share the markup: board beside the panel from 700px up, board over the
|
||||
panel below; the host's embed frame switches its height at that width. */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
@@ -45,16 +41,14 @@
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
|
||||
/* Side-by-side sizes: the board edge includes its coordinate margin. The
|
||||
panel takes a quarter of the width between 260px and 420px; the board
|
||||
takes everything else the window leaves beside it and under the top
|
||||
bar, so the margins give way first, the board second, and the panel
|
||||
is never pushed out of view. 3.5rem and 2rem are the app's padding and
|
||||
gaps (8px each side, a gap of up to 2rem beside the board) plus 8px of
|
||||
slack; the host's embed sizes its frame to this formula. */
|
||||
/* Side by side, the board (its edge holds the coordinates) takes the
|
||||
height under the top bar, or the width the panel's minimum leaves, and
|
||||
the panel takes the rest of the row. 3.5rem and 2rem are the app's
|
||||
padding and gaps (8px each side, a gap of up to 2rem) plus 8px of slack;
|
||||
the host's embed sizes its frame to this formula. */
|
||||
--topbar-height: 2.5rem;
|
||||
--panel-width: clamp(260px, 24vw, 420px);
|
||||
--board-edge: clamp(280px, min(100vw - var(--panel-width) - 3.5rem, 100vh - var(--topbar-height) - 2rem), 840px);
|
||||
--panel-min: 260px;
|
||||
--board-edge: clamp(280px, min(100vw - var(--panel-min) - 3.5rem, 100vh - var(--topbar-height) - 2rem), 840px);
|
||||
--board-pad: clamp(14px, 1.04vw, 20px);
|
||||
}
|
||||
|
||||
@@ -399,9 +393,12 @@ body[data-mode="replay"] .square, .square.inert { cursor: default; }
|
||||
|
||||
/* --- Panel ----------------------------------------------------------- */
|
||||
|
||||
/* Up to 48rem: a full window on a large screen centres the pair rather than
|
||||
stretching the controls across it. */
|
||||
.panel {
|
||||
flex: none;
|
||||
width: var(--panel-width);
|
||||
flex: 1 1 0;
|
||||
min-width: var(--panel-min);
|
||||
max-width: 48rem;
|
||||
height: var(--board-edge);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
@@ -1104,9 +1101,6 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
than the controls dropping below the fold. */
|
||||
--board-edge: max(240px, min(100vw, calc(100svh - 15.75rem)));
|
||||
--board-pad: 14px;
|
||||
--panel-width: 100%;
|
||||
--panel-height: auto;
|
||||
--topbar-height: 2.5rem;
|
||||
}
|
||||
|
||||
body { overflow-y: auto; overflow-x: hidden; }
|
||||
@@ -1154,6 +1148,8 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
|
||||
Reference in new issue
Block a user