4.8 KiB
Stage 21 — user_name + display_name refactor
Context
The Game Lobby plan moved every in-game race_name value into the Lobby Race
Name Directory. User Service stopped owning any in-game naming concept. The
legacy single-valued RaceName field on UserAccount, the canonical
race-name reservation store, and the RaceNamePolicy port were deleted. Two
stable fields replace them:
user_name— immutable auto-generatedplayer-<suffix>handle, unique platform-wide, assigned once at account creation;display_name— mutable optional free-text label validated bypkg/util/string.go:ValidateTypeName, empty by default, not unique.
Key decisions
Crockford Base32 lowercase alphabet for the suffix
user_name ends with eight characters drawn from the alphabet
0123456789abcdefghjkmnpqrstvwxyz (Crockford Base32 lowercase, i, l,
o, u excluded). Each player- identifier therefore has 40 bits of
entropy and is free of visually ambiguous pairs.
The generator lives in
user/internal/adapters/local/id_generator.go:randomSuffix; implementation
reads five random bytes via crypto/rand and walks the 5-bit groups through
the alphabet without using encoding/base32 so the alphabet swap stays
self-contained.
Retry limit bumped to 10
authdirectory.Ensurer.ensureCreateRetryLimit moved from 8 to 10.
Collisions on the 40-bit suffix are expected to be extremely rare; the extra
two attempts give a comfortable margin before the service falls back to
503 service_unavailable.
Canonical reservation removed
RaceNameReservation, RaceNameCanonicalKey, ErrRaceNameConflict, and
the reservation:race-name:* Redis keys are gone. Uniqueness now comes from
the single lookup:user-name:<encoded> index; no canonical form is
persisted in User Service. Any existing Redis dataset must be re-initialized
(the codebase has no production deployment, so no migration script ships).
Confusable policy moved to Lobby
user/internal/ports/race_name_policy.go and
user/internal/adapters/local/race_name_policy.go were deleted. The
Unicode case-fold + digit-to-letter anti-fraud map + TR39 confusable
skeleton landed under lobby/internal/domain/racename/ so Stage 09R can
wire it into the new Race Name Directory. Golden fixtures (Pilot Nova vs
P1lot N0va, unicode paypal vs Cyrillic lookalike) moved with the policy.
Admin surface
/api/v1/internal/user-lookups/by-race-nameis replaced by/api/v1/internal/user-lookups/by-user-name(exact 1:1 lookup).- Admin listing
GET /api/v1/internal/usersgainsuser_name,display_name, anddisplay_name_matchquery parameters.display_namesupportsexact(default) andprefixmatching.
Empty display_name
An empty or whitespace-only input value on POST /profile is accepted and
stored as an empty display_name. Non-empty values are validated by
pkg/util/string.go:ValidateTypeName; internal whitespace, leading/trailing
special characters, and unsupported characters are rejected.
Eligibility snapshot gains max_registered_race_names
lobbyeligibility.limitCatalog now covers every tariff explicitly. The
defaults for max_registered_race_names are free=1, paid_monthly=2,
paid_yearly=6, paid_lifetime=0 (unlimited marker). A
LimitCodeMaxRegisteredRaceNames user-specific override, when active,
replaces the tariff default. This feeds Stage 17A's registration quota
enforcement and Stage 22's permanent_block cascading.
Domain event payload
ports.ProfileChangedEvent.RaceName was replaced by UserName and
DisplayName. The Redis stream publisher emits user_name on every event
and display_name only when non-empty, keeping the event type
user.profile.changed stable.
Telemetry rename
Runtime.RecordRaceNameReservationConflict is now
Runtime.RecordUserNameConflict; the metric name changed from
user.race_name.reservation_conflicts to user.user_name.conflicts.
Gateway boundary
The cross-module FlatBuffers boundary also moved:
pkg/schema/fbs/user.fbs swaps AccountView.race_name for
user_name + display_name, and UpdateMyProfileRequest.race_name for
display_name. pkg/transcoder/user.go, pkg/model/user, gateway
downstream tests, and the integration harness mirror the change.
Files of interest
- Domain:
user/internal/domain/common/types.go,user/internal/domain/account/model.go,user/internal/domain/policy/model.go - Storage:
user/internal/adapters/redisstate/keyspace.go,user/internal/adapters/redis/userstore/store.go - Services:
user/internal/service/{authdirectory,selfservice,adminusers,accountview,lobbyeligibility,shared}/ - HTTP + OpenAPI:
user/internal/api/internalhttp/,user/openapi.yaml - Lobby seed:
lobby/internal/domain/racename/{policy,types,policy_test}.go - Gateway boundary:
pkg/schema/fbs/user.fbs,pkg/transcoder/user.go,pkg/model/user/user.go