Skip to content

Commit 877d944

Browse files
amtiYoclaude
andcommitted
feat(result): the DM result is the world animation of the day
A shared DM result showed one pin, the person's own, and the animation with everybody who played the day was nowhere. The DM result now goes out as the world animation: every answer scored so far on one globe and the first three on the podium with their photos. The caption keeps the person's own day and adds their place in the world so far. Sharing from the Mini App sends the same animation. The day is still open in later time zones at a DM's 20:00, so the result carries a Refresh button and an italic note on when the ranking is final. Refresh redraws with the answers scored since, at most every two minutes per language, and the bot reads updates one by one, so the drawing runs in the background and a toast says what happens. Two minutes after the last zone's 20:00 a job puts the final picture in every DM result and takes the button away. The picture is the same for everyone who reads a language, so it is drawn once per version of the ranking and then sent by file_id (migration 011_world_card.sql: the world_card table, the shown version on chat_round and the job kind "world"). Without ffmpeg DM results go out as before. The world podium writes distances under a kilometre in metres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 79dbe5f commit 877d944

23 files changed

Lines changed: 1619 additions & 36 deletions

‎CHANGELOG.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,26 @@ See [`docs/versioning.md`](docs/versioning.md) for what the numbers mean and
1111

1212
## Unreleased
1313

14+
Operators: migration `011_world_card.sql` runs on start with
15+
`AUTO_MIGRATE=true`: a `world_card` table, a `world_version` column on
16+
`chat_round` and the job kind `world`. The world animation is drawn with
17+
ffmpeg; without it DM results go out as before.
18+
19+
### Changed
20+
21+
- The DM result is the world animation of the day: every answer scored so
22+
far on one globe and the first three on the podium, with their photos.
23+
The caption keeps the person's own day and adds their place in the world
24+
so far. Sharing the result from the Mini App sends the same animation,
25+
so the shared post shows everyone, not one pin.
26+
- Until the day closes in every time zone the result carries a Refresh
27+
button and an italic note on when the ranking is final. Refresh redraws
28+
the picture with the answers scored since, at most once every two
29+
minutes per language, and says when there is nothing new.
30+
- Two minutes after the last zone's 20:00 the final picture replaces the
31+
one in every DM result, with the final place and without the button.
32+
- The world animation writes distances under a kilometre in metres.
33+
1434
## v0.0.1-alpha.7 - 2026-10-26
1535

1636
Results show the players' faces, the DM result is about the person's own

‎cmd/maply/main.go‎

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/FreshLabDev/maply/internal/round"
2828
"github.com/FreshLabDev/maply/internal/schedule"
2929
"github.com/FreshLabDev/maply/internal/telegram"
30+
"github.com/FreshLabDev/maply/internal/world"
3031
"github.com/FreshLabDev/maply/migrations"
3132
"github.com/FreshLabDev/maply/web"
3233
)
@@ -108,7 +109,6 @@ func run(log *slog.Logger) error {
108109
opts.Profiles = profiles
109110
go profiles.Run(ctx, 10*time.Minute)
110111
}
111-
b := bot.New(store, tgc, log, opts)
112112
// The players' photos for the pins and tables of the results.
113113
avatars := &avatar.Service{Store: store, TG: tgc, Log: log}
114114

@@ -132,6 +132,33 @@ func run(log *slog.Logger) error {
132132
log.Info("result cards on", "renderer", cfg.CardRendererURL, "sharing", cards.CanShare())
133133
}
134134

135+
var sched *schedule.Scheduler
136+
if cfg.Schedule {
137+
sched = &schedule.Scheduler{
138+
Store: store,
139+
TG: tgc,
140+
Weather: schedule.OpenMeteo{},
141+
Payloads: signer,
142+
Zones: zones,
143+
Countries: countries,
144+
ImagesDir: cfg.ImagesDir,
145+
Username: me.Username,
146+
AppURL: cfg.AppURL,
147+
Avatars: avatars,
148+
Log: log,
149+
}
150+
if cards != nil {
151+
sched.Cards = cards
152+
}
153+
if ffmpeg != "" {
154+
sched.Anim = &schedule.Animator{FFmpeg: ffmpeg}
155+
// A DM's result is the whole world's day on one globe.
156+
sched.World = &world.Service{Store: store, Zones: zones, FFmpeg: ffmpeg, Avatars: avatars, Log: log}
157+
opts.World = sched
158+
}
159+
}
160+
b := bot.New(store, tgc, log, opts)
161+
135162
gameAPI := &api.Server{
136163
Store: store,
137164
InitData: auth.NewVerifier(cfg.TelegramBotToken),
@@ -190,26 +217,7 @@ func run(log *slog.Logger) error {
190217
}
191218
}()
192219

193-
if cfg.Schedule {
194-
sched := &schedule.Scheduler{
195-
Store: store,
196-
TG: tgc,
197-
Weather: schedule.OpenMeteo{},
198-
Payloads: signer,
199-
Zones: zones,
200-
Countries: countries,
201-
ImagesDir: cfg.ImagesDir,
202-
Username: me.Username,
203-
AppURL: cfg.AppURL,
204-
Avatars: avatars,
205-
Log: log,
206-
}
207-
if cards != nil {
208-
sched.Cards = cards
209-
}
210-
if ffmpeg != "" {
211-
sched.Anim = &schedule.Animator{FFmpeg: ffmpeg}
212-
}
220+
if sched != nil {
213221
go func() {
214222
log.Info("scheduler starting")
215223
if err := sched.Run(ctx); err != nil {

‎internal/anim/world.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ type World struct {
2727
Guesses []Guess
2828
Top [3]*Winner // the first three; nil where there are fewer
2929
Unit string
30-
Lang language.Language
30+
// UnitM writes a distance under a kilometre in metres; empty keeps it
31+
// in kilometres.
32+
UnitM string
33+
Lang language.Language
3134
}
3235

3336
const worldDur = 12
@@ -100,5 +103,5 @@ func (w *World) Draw(c *Canvas, t float64) {
100103
c.Pin(78, 172, 30, PinStyle{Fill: white, Hole: deep, NoEdge: true, EdgeWidth: 1}, 1, clip)
101104
c.Text(Display, 40, s, 98, 173, Left, white, 1, clip)
102105
}
103-
c.podium(worldPodium, w.Top, w.Unit, "", t)
106+
c.podium(worldPodium, w.Top, w.Unit, w.UnitM, t)
104107
}

‎internal/api/api_integration_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func setup(t *testing.T) *harness {
9393
t.Fatal(err)
9494
}
9595
for _, stmt := range []string{
96-
`TRUNCATE maply.job, maply.chat_round, maply.guess, maply.site_guess, maply.site_player, maply.chat_settings, maply.round, maply.place, maply.avatar`,
96+
`TRUNCATE maply.job, maply.chat_round, maply.guess, maply.site_guess, maply.site_player, maply.chat_settings, maply.round, maply.place, maply.avatar, maply.world_card`,
9797
`TRUNCATE core.person, core.chat CASCADE`,
9898
`TRUNCATE core.language_observation`,
9999
} {

‎internal/bot/bot.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/FreshLabDev/maply/internal/db"
1313
"github.com/FreshLabDev/maply/internal/round"
14+
"github.com/FreshLabDev/maply/internal/schedule"
1415
"github.com/FreshLabDev/maply/internal/telegram"
1516
)
1617

@@ -69,6 +70,15 @@ type Options struct {
6970
// Profiles draws the statistics picture; nil shows the statistics as
7071
// text only.
7172
Profiles Profiles
73+
// World refreshes the world picture of a DM's result; nil leaves its
74+
// Refresh button doing nothing.
75+
World WorldRefresher
76+
}
77+
78+
// WorldRefresher brings the world picture under a DM's result up to date;
79+
// see schedule.Scheduler.RefreshWorld.
80+
type WorldRefresher interface {
81+
RefreshWorld(ctx context.Context, chatID, msgID int64, d round.Date) (schedule.Refresh, error)
7282
}
7383

7484
type Bot struct {

‎internal/bot/bot_test.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ type fakeTelegram struct {
161161
deleted []int64
162162
alerts []string
163163
answers int
164+
toasts []string
164165
members map[int64]string
165166
anims []sent
166167
animated chan struct{} // told after each animation, when set
@@ -214,8 +215,9 @@ func (f *fakeTelegram) DeleteMessage(_ context.Context, _, messageID int64) erro
214215
return nil
215216
}
216217

217-
func (f *fakeTelegram) AnswerCallbackQuery(context.Context, string, string) error {
218+
func (f *fakeTelegram) AnswerCallbackQuery(_ context.Context, _, text string) error {
218219
f.answers++
220+
f.toasts = append(f.toasts, text)
219221
return nil
220222
}
221223

‎internal/bot/panels.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/FreshLabDev/maply/internal/db"
1313
"github.com/FreshLabDev/maply/internal/i18n"
1414
"github.com/FreshLabDev/maply/internal/round"
15+
"github.com/FreshLabDev/maply/internal/schedule"
1516
"github.com/FreshLabDev/maply/internal/telegram"
1617
)
1718

@@ -139,6 +140,9 @@ func isPanel(m tg.Message) bool {
139140
}
140141

141142
func (b *Bot) onCallback(ctx context.Context, cq *tg.CallbackQuery) error {
143+
if d, ok := schedule.WorldCallback(cq.Data); ok {
144+
return b.onRefreshWorld(ctx, cq, d)
145+
}
142146
owner, action, ok := parseMenuCB(cq.Data)
143147
if !ok {
144148
return b.tg.AnswerCallbackQuery(ctx, cq.ID, "")

‎internal/bot/world.go‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
package bot
3+
4+
import (
5+
"context"
6+
7+
"github.com/FreshLabDev/tg"
8+
9+
"github.com/FreshLabDev/maply/internal/i18n"
10+
"github.com/FreshLabDev/maply/internal/round"
11+
"github.com/FreshLabDev/maply/internal/schedule"
12+
)
13+
14+
// onRefreshWorld is a press of Refresh under a DM's result: the world
15+
// picture is redrawn with the answers scored since, and a toast says what
16+
// happens. Only the person whose DM it is can press it.
17+
func (b *Bot) onRefreshWorld(ctx context.Context, cq *tg.CallbackQuery, d round.Date) error {
18+
m := cq.Message
19+
if b.opts.World == nil || m.Chat.Type != "private" || m.Chat.ID != cq.From.ID || m.MessageID == 0 {
20+
return b.tg.AnswerCallbackQuery(ctx, cq.ID, "")
21+
}
22+
raw, err := b.personLang(ctx, cq.From)
23+
if err != nil {
24+
_ = b.tg.AnswerCallbackQuery(ctx, cq.ID, "")
25+
return err
26+
}
27+
lang := i18n.Resolve(raw)
28+
r, err := b.opts.World.RefreshWorld(ctx, m.Chat.ID, m.MessageID, d)
29+
text := ""
30+
switch r {
31+
case schedule.RefreshSame:
32+
text = i18n.T(lang, "world.refresh_same")
33+
case schedule.RefreshFinal:
34+
text = i18n.T(lang, "world.refresh_final")
35+
case schedule.RefreshDrawing:
36+
text = i18n.T(lang, "world.refresh_drawing")
37+
}
38+
if answerErr := b.tg.AnswerCallbackQuery(ctx, cq.ID, text); err == nil {
39+
err = answerErr
40+
}
41+
return err
42+
}

‎internal/bot/world_test.go‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
package bot
3+
4+
import (
5+
"context"
6+
"io"
7+
"log/slog"
8+
"testing"
9+
10+
"github.com/FreshLabDev/tg"
11+
12+
"github.com/FreshLabDev/maply/internal/round"
13+
"github.com/FreshLabDev/maply/internal/schedule"
14+
)
15+
16+
type fakeRefresher struct {
17+
r schedule.Refresh
18+
calls []round.Date
19+
}
20+
21+
func (f *fakeRefresher) RefreshWorld(_ context.Context, _, _ int64, d round.Date) (schedule.Refresh, error) {
22+
f.calls = append(f.calls, d)
23+
return f.r, nil
24+
}
25+
26+
// Refresh works in the person's own DM only and says what it does.
27+
func TestRefreshWorld(t *testing.T) {
28+
ctx := context.Background()
29+
d := round.NewDate(2026, 11, 2)
30+
press := func(chat tg.Chat, from int64) *tg.CallbackQuery {
31+
return &tg.CallbackQuery{ID: "q", From: tg.User{ID: from, LanguageCode: "en"}, Data: "wr:" + d.String(),
32+
Message: tg.Message{MessageID: 5, Chat: chat}}
33+
}
34+
for _, c := range []struct {
35+
name string
36+
r schedule.Refresh
37+
cq *tg.CallbackQuery
38+
calls int
39+
toast string
40+
}{
41+
{"drawing", schedule.RefreshDrawing, press(tg.Chat{ID: 9, Type: "private"}, 9), 1, "Drawing the map with the new answers, this takes up to a minute."},
42+
{"nothing new", schedule.RefreshSame, press(tg.Chat{ID: 9, Type: "private"}, 9), 1, "No new answers yet."},
43+
{"final", schedule.RefreshFinal, press(tg.Chat{ID: 9, Type: "private"}, 9), 1, "The day's ranking is final."},
44+
{"gone", schedule.RefreshGone, press(tg.Chat{ID: 9, Type: "private"}, 9), 1, ""},
45+
{"in a group", schedule.RefreshDrawing, press(tg.Chat{ID: -100, Type: "supergroup"}, 9), 0, ""},
46+
} {
47+
t.Run(c.name, func(t *testing.T) {
48+
client := &fakeTelegram{}
49+
world := &fakeRefresher{r: c.r}
50+
b := New(newStore(), client, slog.New(slog.NewTextHandler(io.Discard, nil)), Options{World: world})
51+
if err := b.onCallback(ctx, c.cq); err != nil {
52+
t.Fatal(err)
53+
}
54+
if len(world.calls) != c.calls || (c.calls > 0 && world.calls[0] != d) {
55+
t.Errorf("refreshes = %v", world.calls)
56+
}
57+
if len(client.toasts) != 1 || client.toasts[0] != c.toast {
58+
t.Errorf("toasts = %q, want %q", client.toasts, c.toast)
59+
}
60+
})
61+
}
62+
}

‎internal/db/db_integration_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func testStore(t *testing.T) *Store {
5151
reset := func() {
5252
for _, stmt := range []string{
5353
`UPDATE maply.runtime_state SET telegram_offset=0`,
54-
`TRUNCATE maply.job, maply.chat_round, maply.guess, maply.site_guess, maply.site_player, maply.chat_settings, maply.round, maply.place, maply.avatar`,
54+
`TRUNCATE maply.job, maply.chat_round, maply.guess, maply.site_guess, maply.site_player, maply.chat_settings, maply.round, maply.place, maply.avatar, maply.world_card`,
5555
`TRUNCATE core.person, core.chat CASCADE`,
5656
`TRUNCATE core.language_observation`,
5757
} {

0 commit comments

Comments
 (0)