feat(robot): occasional off-strategy deviation, strict in the endgame #69
@@ -648,7 +648,10 @@ cannot submit; three-way admin filter.
|
|||||||
- **Margin** (interview): pick the candidate whose resulting margin (own+move−opp)
|
- **Margin** (interview): pick the candidate whose resulting margin (own+move−opp)
|
||||||
is closest to **[1,30]** when playing to win / **[−30,−1]** when playing to lose,
|
is closest to **[1,30]** when playing to win / **[−30,−1]** when playing to lose,
|
||||||
tie-broken toward the conservative edge; no legal play → exchange the full rack
|
tie-broken toward the conservative edge; no legal play → exchange the full rack
|
||||||
when the bag can refill it, else pass.
|
when the bag can refill it, else pass. *(Later refined — separate PR: on ≈20% of
|
||||||
|
opening/midgame moves the robot flips its intent for that one move, the chance
|
||||||
|
tapering to 0 over the last 14 bag tiles and 0 at an empty bag, so the endgame
|
||||||
|
stays strict; deterministic from the seed.)*
|
||||||
- **Substitution** (interview): a matchmaker **reaper** (`Reap`/`RunReaper`)
|
- **Substitution** (interview): a matchmaker **reaper** (`Reap`/`RunReaper`)
|
||||||
substitutes a pooled robot after a **10 s** wait (`BACKEND_LOBBY_ROBOT_WAIT`),
|
substitutes a pooled robot after a **10 s** wait (`BACKEND_LOBBY_ROBOT_WAIT`),
|
||||||
`NewMatchmaker` now takes a `RobotProvider`. A waiter learns of a match — human
|
`NewMatchmaker` now takes a `RobotProvider`. A waiter learns of a match — human
|
||||||
|
|||||||
@@ -511,3 +511,11 @@ Then Stage 18.
|
|||||||
(`GameRow`/`GameDetailView` gain `VsAI`); (4) `games_started_total` / `games_abandoned_total` gain a
|
(`GameRow`/`GameDetailView` gain `VsAI`); (4) `games_started_total` / `games_abandoned_total` gain a
|
||||||
**`vs_ai`** attribute and the Grafana *Game domain* dashboard splits started/abandoned into **human**
|
**`vs_ai`** attribute and the Grafana *Game domain* dashboard splits started/abandoned into **human**
|
||||||
and **AI** panels.
|
and **AI** panels.
|
||||||
|
- **Follow-up (separate PR — strategy deviation):** the robot now plays **≈20%** of opening/midgame moves
|
||||||
|
*against* its per-game `playToWin` intent (toward the opposite margin band — a winning robot eases off, a
|
||||||
|
losing one surges ahead), tapering linearly to **0 over the last 14 bag tiles** and **0 once the bag is
|
||||||
|
empty**, so the endgame follows the chosen strategy strictly while earlier outcomes can swing the human's
|
||||||
|
way. Deterministic from the seed (`mix(seed,"deviate",moveCount)`), applied to **both** robot paths via
|
||||||
|
the shared `selectMove`; the per-game intent (and the admin card) is unchanged. Tests: `robot` unit
|
||||||
|
(taper bounds + monotonicity, never-in-endgame, determinism, ~20% distribution). Bake-back:
|
||||||
|
`docs/ARCHITECTURE.md` §7, `docs/FUNCTIONAL.md` (+`_ru`), `backend/README.md`, `PLAN.md` Stage 5.
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ each a `kind='robot'` identity, provisioned at startup with chat and friend
|
|||||||
requests blocked — backs human-like, per-language composed names. A background driver plays the
|
requests blocked — backs human-like, per-language composed names. A background driver plays the
|
||||||
robot's moves through the public game API as an ordinary seated player (so only
|
robot's moves through the public game API as an ordinary seated player (so only
|
||||||
`internal/engine` imports the solver): it decides once per game whether to play to
|
`internal/engine` imports the solver): it decides once per game whether to play to
|
||||||
win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware
|
win (≈ 40%), targets a small score margin — with an occasional off-strategy move that tapers to
|
||||||
|
none as the bag empties — and times its moves with a move-number-aware
|
||||||
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
||||||
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
||||||
state. A background **reaper** seats a pooled robot (matching the game's language) in any open
|
state. A background **reaper** seats a pooled robot (matching the game's language) in any open
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ func (s *Service) handle(ctx context.Context, rt game.RobotTurn, now time.Time)
|
|||||||
// Honest-AI game: the robot moves the instant it is its turn — no sleep window and
|
// Honest-AI game: the robot moves the instant it is its turn — no sleep window and
|
||||||
// no proactive nudge (chat and nudge are disabled in these games, and the player
|
// no proactive nudge (chat and nudge are disabled in these games, and the player
|
||||||
// chose an opponent that "moves at once"). It still plays to the same per-game
|
// chose an opponent that "moves at once"). It still plays to the same per-game
|
||||||
// strength (playToWin) and margin band as the human-mimicry path.
|
// strength (playToWin), margin band and occasional off-strategy deviation as the
|
||||||
|
// human-mimicry path.
|
||||||
if rt.VsAI {
|
if rt.VsAI {
|
||||||
if rt.ToMove == rt.RobotSeat {
|
if rt.ToMove == rt.RobotSeat {
|
||||||
return s.act(ctx, rt, now)
|
return s.act(ctx, rt, now)
|
||||||
@@ -164,9 +165,11 @@ func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Ti
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// act reads the live turn, chooses a move by margin and submits it. State that
|
// act reads the live turn, chooses a move by margin — usually toward the robot's
|
||||||
// has moved on since the scan (a finished game, a turn that is no longer the
|
// per-game intent, but with an occasional off-strategy deviation that fades to none
|
||||||
// robot's) surfaces as a benign error and is skipped.
|
// as the bag empties — and submits it. State that has moved on since the scan (a
|
||||||
|
// finished game, a turn that is no longer the robot's) surfaces as a benign error
|
||||||
|
// and is skipped.
|
||||||
func (s *Service) act(ctx context.Context, rt game.RobotTurn, now time.Time) error {
|
func (s *Service) act(ctx context.Context, rt game.RobotTurn, now time.Time) error {
|
||||||
st, err := s.games.GameState(ctx, rt.GameID, rt.RobotID)
|
st, err := s.games.GameState(ctx, rt.GameID, rt.RobotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -179,7 +182,11 @@ func (s *Service) act(ctx context.Context, rt game.RobotTurn, now time.Time) err
|
|||||||
|
|
||||||
myScore := st.Game.Seats[st.Seat].Score
|
myScore := st.Game.Seats[st.Seat].Score
|
||||||
oppScore := bestOpponentScore(st.Game.Seats, st.Seat)
|
oppScore := bestOpponentScore(st.Game.Seats, st.Seat)
|
||||||
d := selectMove(cands, myScore, oppScore, playToWin(rt.Seed), defaultBand, st.Rack, st.BagLen)
|
win := playToWin(rt.Seed)
|
||||||
|
if deviates(rt.Seed, rt.MoveCount, st.BagLen) {
|
||||||
|
win = !win // an occasional off-strategy move; never once the bag is empty
|
||||||
|
}
|
||||||
|
d := selectMove(cands, myScore, oppScore, win, defaultBand, st.Rack, st.BagLen)
|
||||||
|
|
||||||
var res game.MoveResult
|
var res game.MoveResult
|
||||||
switch d.kind {
|
switch d.kind {
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ const (
|
|||||||
// human wins about 60% of games (docs/ARCHITECTURE.md §7).
|
// human wins about 60% of games (docs/ARCHITECTURE.md §7).
|
||||||
playToWinPercent = 40
|
playToWinPercent = 40
|
||||||
|
|
||||||
|
// The robot occasionally plays a single move against its per-game win/lose
|
||||||
|
// intent (an off-strategy "wobble"), so the chosen strategy may not pan out —
|
||||||
|
// which favours the human. deviateMaxProb is the peak probability of that, held
|
||||||
|
// through the opening and midgame; it tapers linearly to 0 over the last
|
||||||
|
// deviateTaperTiles tiles left in the bag, reaching 0 once the bag is empty, so
|
||||||
|
// the endgame follows the chosen strategy strictly (docs/ARCHITECTURE.md §7).
|
||||||
|
deviateMaxProb = 0.20
|
||||||
|
deviateTaperTiles = 14
|
||||||
|
|
||||||
// The robot's think time depends on how far the game has progressed: early moves
|
// The robot's think time depends on how far the game has progressed: early moves
|
||||||
// are quick and late moves can be long (endgame deliberation). The delay is drawn
|
// are quick and late moves can be long (endgame deliberation). The delay is drawn
|
||||||
// from a band that interpolates with the move count from [delayEarlyLoMinutes,
|
// from a band that interpolates with the move count from [delayEarlyLoMinutes,
|
||||||
@@ -129,6 +138,34 @@ func PlayToWin(seed int64) bool { return playToWin(seed) }
|
|||||||
// win in any given game (the admin console shows it alongside the per-game decision).
|
// win in any given game (the admin console shows it alongside the per-game decision).
|
||||||
const PlayToWinTargetPercent = playToWinPercent
|
const PlayToWinTargetPercent = playToWinPercent
|
||||||
|
|
||||||
|
// deviateProb is the probability that the robot plays a single move against its
|
||||||
|
// per-game win/lose intent, given the number of tiles left in the bag. It is
|
||||||
|
// deviateMaxProb through the opening and midgame, then tapers linearly to 0 over
|
||||||
|
// the last deviateTaperTiles tiles, reaching 0 once the bag is empty so the endgame
|
||||||
|
// follows the chosen strategy strictly.
|
||||||
|
func deviateProb(bagLen int) float64 {
|
||||||
|
switch {
|
||||||
|
case bagLen <= 0:
|
||||||
|
return 0
|
||||||
|
case bagLen >= deviateTaperTiles:
|
||||||
|
return deviateMaxProb
|
||||||
|
default:
|
||||||
|
return deviateMaxProb * float64(bagLen) / float64(deviateTaperTiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviates reports whether the robot deviates from its per-game win/lose intent on
|
||||||
|
// the move at moveCount: a deterministic per-turn draw (mix/unitFloat, like the
|
||||||
|
// think-time sampling) against deviateProb(bagLen), so it is reproducible across
|
||||||
|
// restarts and never fires once the bag is empty.
|
||||||
|
func deviates(seed int64, moveCount, bagLen int) bool {
|
||||||
|
p := deviateProb(bagLen)
|
||||||
|
if p <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return unitFloat(mix(seed, "deviate", moveCount)) < p
|
||||||
|
}
|
||||||
|
|
||||||
// NextMoveAt is the deterministic instant the robot is scheduled to play the move at
|
// NextMoveAt is the deterministic instant the robot is scheduled to play the move at
|
||||||
// moveCount, given when the turn started and the opponent's timezone (which anchors the
|
// moveCount, given when the turn started and the opponent's timezone (which anchors the
|
||||||
// robot's sleep window). It is the sampled think-time delay, deferred to the end of the
|
// robot's sleep window). It is the sampled think-time delay, deferred to the end of the
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package robot
|
package robot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -238,6 +239,77 @@ func TestPlayToWinExport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDeviateProbTaper checks the deviation probability is deviateMaxProb while the
|
||||||
|
// bag holds at least deviateTaperTiles tiles, halves at the taper midpoint, is 0
|
||||||
|
// once the bag is empty, and stays within [0, deviateMaxProb] and non-decreasing.
|
||||||
|
func TestDeviateProbTaper(t *testing.T) {
|
||||||
|
if p := deviateProb(0); p != 0 {
|
||||||
|
t.Errorf("deviateProb(0) = %v, want 0 (strict endgame)", p)
|
||||||
|
}
|
||||||
|
if p := deviateProb(deviateTaperTiles); p != deviateMaxProb {
|
||||||
|
t.Errorf("deviateProb(%d) = %v, want %v", deviateTaperTiles, p, deviateMaxProb)
|
||||||
|
}
|
||||||
|
if p := deviateProb(deviateTaperTiles + 50); p != deviateMaxProb {
|
||||||
|
t.Errorf("deviateProb above the taper = %v, want %v (capped)", p, deviateMaxProb)
|
||||||
|
}
|
||||||
|
if p := deviateProb(deviateTaperTiles / 2); math.Abs(p-deviateMaxProb/2) > 1e-9 {
|
||||||
|
t.Errorf("deviateProb at half taper = %v, want ~%v", p, deviateMaxProb/2)
|
||||||
|
}
|
||||||
|
prev := -1.0
|
||||||
|
for bag := 0; bag <= deviateTaperTiles+5; bag++ {
|
||||||
|
p := deviateProb(bag)
|
||||||
|
if p < 0 || p > deviateMaxProb {
|
||||||
|
t.Fatalf("deviateProb(%d) = %v out of [0,%v]", bag, p, deviateMaxProb)
|
||||||
|
}
|
||||||
|
if p < prev {
|
||||||
|
t.Fatalf("deviateProb not non-decreasing: bag %d gives %v after %v", bag, p, prev)
|
||||||
|
}
|
||||||
|
prev = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeviatesNeverInEndgame checks the robot never deviates once the bag is empty,
|
||||||
|
// for every seed and move count, so the endgame follows the chosen strategy strictly.
|
||||||
|
func TestDeviatesNeverInEndgame(t *testing.T) {
|
||||||
|
for seed := int64(1); seed <= 5000; seed++ {
|
||||||
|
for mc := 0; mc < 40; mc++ {
|
||||||
|
if deviates(seed, mc, 0) {
|
||||||
|
t.Fatalf("deviates with an empty bag for seed=%d mc=%d", seed, mc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeviatesDeterministic checks the per-turn deviation draw is reproducible for a
|
||||||
|
// (seed, moveCount, bagLen), so the driver recomputes the same decision on every scan.
|
||||||
|
func TestDeviatesDeterministic(t *testing.T) {
|
||||||
|
for seed := int64(1); seed <= 500; seed++ {
|
||||||
|
for mc := 0; mc < 30; mc++ {
|
||||||
|
got := deviates(seed, mc, deviateTaperTiles)
|
||||||
|
if deviates(seed, mc, deviateTaperTiles) != got {
|
||||||
|
t.Fatalf("deviates not deterministic for seed=%d mc=%d", seed, mc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeviatesDistribution checks the deviation rate over many games lands near
|
||||||
|
// deviateMaxProb while the bag is full (above the taper), at a fixed move count.
|
||||||
|
func TestDeviatesDistribution(t *testing.T) {
|
||||||
|
const n = 20000
|
||||||
|
hits := 0
|
||||||
|
for seed := int64(1); seed <= n; seed++ {
|
||||||
|
if deviates(seed, 3, deviateTaperTiles+20) {
|
||||||
|
hits++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pct := float64(hits) / float64(n) * 100
|
||||||
|
want := deviateMaxProb * 100
|
||||||
|
if pct < want-2 || pct > want+2 {
|
||||||
|
t.Errorf("deviation rate = %.1f%%, want ~%.0f%% (±2)", pct, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestProactiveNudgeGap checks the proactive-nudge schedule: the first gap (refIdle 0) is
|
// TestProactiveNudgeGap checks the proactive-nudge schedule: the first gap (refIdle 0) is
|
||||||
// ~60-90 min, every gap stays within [60 min, 6 h] and is deterministic, and the gap lengthens
|
// ~60-90 min, every gap stays within [60 min, 6 h] and is deterministic, and the gap lengthens
|
||||||
// as the idle grows (the median at 12 h idle exceeds the median at the start).
|
// as the idle grows (the median at 12 h idle exceeds the median at the start).
|
||||||
|
|||||||
@@ -407,7 +407,14 @@ English game the Latin pool.
|
|||||||
(`engine.Candidates`) the move whose resulting lead (playing to win) or deficit
|
(`engine.Candidates`) the move whose resulting lead (playing to win) or deficit
|
||||||
(playing to lose) is closest to a small band (**1–30 points**), rather than
|
(playing to lose) is closest to a small band (**1–30 points**), rather than
|
||||||
always the maximum; with no legal play it exchanges a full rack when the bag can
|
always the maximum; with no legal play it exchanges a full rack when the bag can
|
||||||
refill it, else passes.
|
refill it, else passes. On **≈20%** of moves through the opening and midgame it
|
||||||
|
**deviates** — playing that single move toward the *opposite* band (a winning
|
||||||
|
robot eases off, a losing one surges ahead), so the chosen strategy may not pan
|
||||||
|
out, which favours the human; the deviation chance tapers linearly to **0 over
|
||||||
|
the last 14 tiles in the bag** and is **0 once the bag is empty**, so the endgame
|
||||||
|
follows the per-game intent strictly. It is **deterministic from the seed**
|
||||||
|
(`mix(seed,"deviate",moveCount)`), a per-move wobble that leaves the per-game
|
||||||
|
play-to-win intent (and the admin card) unchanged.
|
||||||
- **Timing**: the per-move delay is **move-number-aware** — a right-skewed sample
|
- **Timing**: the per-move delay is **move-number-aware** — a right-skewed sample
|
||||||
(exponent k=4, short delays frequent) from a band that interpolates from
|
(exponent k=4, short delays frequent) from a band that interpolates from
|
||||||
**[3, 10] min** at the first move to **[10, 90] min** by ~28 moves, so openings are
|
**[3, 10] min** at the first move to **[10, 90] min** by ~28 moves, so openings are
|
||||||
|
|||||||
+7
-4
@@ -141,8 +141,10 @@ When auto-match finds no human within the wait window (1.5–3 minutes), a robot
|
|||||||
takes the empty seat of the game you are already waiting in. It is meant to feel like a person: it
|
takes the empty seat of the game you are already waiting in. It is meant to feel like a person: it
|
||||||
decides once per game whether to play to win (about 40% of the time, so the human
|
decides once per game whether to play to win (about 40% of the time, so the human
|
||||||
wins most games), aims for a close score rather than crushing or throwing the game,
|
wins most games), aims for a close score rather than crushing or throwing the game,
|
||||||
and plays at a human pace — short thinking times for most moves, the occasional long
|
now and then plays a single move against that plan — a surprise lead or a slack move —
|
||||||
one, and a night-time pause that tracks the player's own day. It answers a nudge
|
yet holds to the plan once the bag empties, and plays at a human pace — short thinking
|
||||||
|
times for most moves, the occasional long one, and a night-time pause that tracks the
|
||||||
|
player's own day. It answers a nudge
|
||||||
within a few minutes and nudges back when the player has been away a long time. It
|
within a few minutes and nudges back when the player has been away a long time. It
|
||||||
carries a human-like, language-appropriate name (a Russian game draws mostly Russian
|
carries a human-like, language-appropriate name (a Russian game draws mostly Russian
|
||||||
names); it does not chat, and **silently ignores friend requests** — a request to a
|
names); it does not chat, and **silently ignores friend requests** — a request to a
|
||||||
@@ -150,8 +152,9 @@ robot stays pending and expires, exactly like a human who never responds.
|
|||||||
|
|
||||||
The same robot also backs the **honest-AI quick game** the player chooses directly (above). There
|
The same robot also backs the **honest-AI quick game** the player chooses directly (above). There
|
||||||
it makes no pretence: it is shown as **🤖** everywhere, joins and moves at once (no thinking time
|
it makes no pretence: it is shown as **🤖** everywhere, joins and moves at once (no thinking time
|
||||||
or night pause), keeps the same strength (it still plays to win only about 40% of the time), and
|
or night pause), keeps the same strength (it still plays to win only about 40% of the time, with
|
||||||
chat, nudge and add-friend are off. AI games are **practice** — they never count toward a player's
|
the same occasional move against its plan that fades out by the endgame), and chat, nudge and
|
||||||
|
add-friend are off. AI games are **practice** — they never count toward a player's
|
||||||
statistics.
|
statistics.
|
||||||
|
|
||||||
### Social: friends, block, chat, nudge
|
### Social: friends, block, chat, nudge
|
||||||
|
|||||||
@@ -146,8 +146,10 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
в которой вы уже ждёте, занимает робот-соперник. Он задуман неотличимым от человека:
|
в которой вы уже ждёте, занимает робот-соперник. Он задуман неотличимым от человека:
|
||||||
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
|
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
|
||||||
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
|
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
|
||||||
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
|
поддавки, время от времени делает один ход вопреки этому плану — неожиданный отрыв или
|
||||||
ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и
|
слабый ход, — но к концу партии (когда мешок пуст) держится плана, и ходит с
|
||||||
|
человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза,
|
||||||
|
подстроенная под день игрока. На nudge отвечает за несколько минут и
|
||||||
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
|
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
|
||||||
языку партии (в русской партии — в основном русские имена); не общается в чате и
|
языку партии (в русской партии — в основном русские имена); не общается в чате и
|
||||||
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
|
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
|
||||||
@@ -155,8 +157,8 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
|
|
||||||
Тот же робот стоит и за **честной игрой против ИИ**, которую игрок выбирает напрямую (выше). Там он
|
Тот же робот стоит и за **честной игрой против ИИ**, которую игрок выбирает напрямую (выше). Там он
|
||||||
не притворяется: везде показан как **🤖**, присоединяется и ходит сразу (без раздумий и ночной
|
не притворяется: везде показан как **🤖**, присоединяется и ходит сразу (без раздумий и ночной
|
||||||
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий), а чат, nudge
|
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
|
||||||
и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||||
|
|
||||||
### Социальное: друзья, блок, чат, nudge
|
### Социальное: друзья, блок, чат, nudge
|
||||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||||
|
|||||||
Reference in New Issue
Block a user