Skip to content

Commit

Permalink
Dynamic leaderboards feature. Merge #43
Browse files Browse the repository at this point in the history
  • Loading branch information
zyro committed Mar 19, 2017
1 parent 163a540 commit 14cbcc7
Show file tree
Hide file tree
Showing 18 changed files with 1,431 additions and 179 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project are documented below.
The format is based on [keep a changelog](http://keepachangelog.com/) and this project uses [semantic versioning](http://semver.org/).

## [Unreleased]
### Added
- Dynamic leaderboards feature.
- Presence updates now report the user's handle.

### Changed
- The build system now strips up to current dir in recorded source file paths at compile.

Expand Down
151 changes: 151 additions & 0 deletions cmd/admin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright 2017 The Nakama Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"database/sql"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"github.com/gorhill/cronexpr"
"github.com/satori/go.uuid"
"github.com/uber-go/zap"
"net/url"
"os"
)

type adminService struct {
DSNS string
logger zap.Logger
}

func AdminParse(args []string, logger zap.Logger) {
if len(args) == 0 {
logger.Fatal("Admin requires a subcommand. Available commands are: 'create-leaderboard'.")
}

var exec func([]string, zap.Logger)
switch args[0] {
case "create-leaderboard":
exec = createLeaderboard
default:
logger.Fatal("Unrecognized admin subcommand. Available commands are: 'create-leaderboard'.")
}

exec(args[1:], logger)
os.Exit(0)
}

func createLeaderboard(args []string, logger zap.Logger) {
var dsns string
var id string
var authoritative bool
var sortOrder string
var resetSchedule string
var metadata string

flags := flag.NewFlagSet("admin", flag.ExitOnError)
flags.StringVar(&dsns, "db", "root@localhost:26257", "CockroachDB JDBC connection details.")
flags.StringVar(&id, "id", "", "ID to assign to the leaderboard.")
flags.BoolVar(&authoritative, "authoritative", false, "True if clients may not submit scores directly, false otherwise.")
flags.StringVar(&sortOrder, "sort", "descending", "Leaderboard sort order, 'asc' or 'desc'.")
flags.StringVar(&resetSchedule, "reset", "", "Optional reset schedule in CRON format.")
flags.StringVar(&metadata, "metadata", "{}", "Optional additional metadata as a JSON string.")

if err := flags.Parse(args); err != nil {
logger.Fatal("Could not parse admin flags.")
}

if dsns == "" {
logger.Fatal("Database connection details are required.")
}

query := `INSERT INTO leaderboard (id, authoritative, sort_order, reset_schedule, metadata)
VALUES ($1, $2, $3, $4, $5)`
params := []interface{}{}

// ID.
if id == "" {
params = append(params, uuid.NewV4().Bytes())
} else {
params = append(params, []byte(id))
}

// Authoritative.
params = append(params, authoritative)

// Sort order.
if sortOrder == "asc" {
params = append(params, 0)
} else if sortOrder == "desc" {
params = append(params, 1)
} else {
logger.Fatal("Invalid sort value, must be 'asc' or 'desc'.")
}

// Count is hardcoded in the INSERT above.

// Reset schedule.
if resetSchedule != "" {
_, err := cronexpr.Parse(resetSchedule)
if err != nil {
logger.Fatal("Reset schedule must be a valid CRON expression.")
}
params = append(params, resetSchedule)
} else {
params = append(params, nil)
}

// Metadata.
metadataBytes := []byte(metadata)
var maybeJSON map[string]interface{}
if json.Unmarshal(metadataBytes, &maybeJSON) != nil {
logger.Fatal("Metadata must be a valid JSON string.")
}
params = append(params, metadataBytes)

rawurl := fmt.Sprintf("postgresql://%s?sslmode=disable", dsns)
url, err := url.Parse(rawurl)
if err != nil {
logger.Fatal("Bad connection URL", zap.Error(err))
}

logger.Info("Database connection", zap.String("dsns", dsns))

// Default to "nakama" as DB name.
dbname := "nakama"
if len(url.Path) > 1 {
dbname = url.Path[1:]
}
url.Path = fmt.Sprintf("/%s", dbname)
db, err := sql.Open(dialect, url.String())
if err != nil {
logger.Fatal("Failed to open database", zap.Error(err))
}
if err = db.Ping(); err != nil {
logger.Fatal("Error pinging database", zap.Error(err))
}

res, err := db.Exec(query, params...)
if err != nil {
logger.Fatal("Error creating leaderboard", zap.Error(err))
}
if rowsAffected, _ := res.RowsAffected(); rowsAffected != 1 {
logger.Fatal("Error creating leaderboard, unexpected insert result")
}

logger.Info("Leaderboard created", zap.String("base64(id)", base64.StdEncoding.EncodeToString(params[0].([]byte))))
}
13 changes: 9 additions & 4 deletions glide.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 12 additions & 4 deletions glide.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ owners:
- name: Mo Firouz
email: [email protected]
import:
- package: golang.org/x/net/context
- package: golang.org/x/crypto/bcrypt
- package: golang.org/x/net
subpackages:
- context
- package: golang.org/x/crypto
subpackages:
- bcrypt
- package: github.com/golang/protobuf
- package: github.com/gogo/protobuf
version: ~0.3.0
Expand All @@ -22,7 +26,7 @@ import:
version: ~1.1
- package: github.com/lib/pq
- package: github.com/rubenv/sql-migrate
- package: github.com/go-gorp/gorp/
- package: github.com/go-gorp/gorp
version: ~2.0.0
- package: github.com/go-yaml/yaml
version: v2
Expand All @@ -32,4 +36,8 @@ import:
- package: github.com/satori/go.uuid
- package: github.com/dgrijalva/jwt-go
version: ~3.0.0
- package: github.com/elazarl/go-bindata-assetfs/...
- package: github.com/elazarl/go-bindata-assetfs
subpackages:
- '...'
- package: github.com/gorhill/cronexpr
version: ~1.0.0
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ func main() {
cmd.DoctorParse(os.Args[2:])
case "migrate":
cmd.MigrateParse(os.Args[2:], clogger)
case "admin":
cmd.AdminParse(os.Args[2:], clogger)
}
}

Expand Down
69 changes: 69 additions & 0 deletions migrations/20170228205100_leaderboards.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright 2017 The Nakama Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

-- +migrate Up
CREATE TABLE IF NOT EXISTS leaderboard (
PRIMARY KEY (id),
FOREIGN KEY (next_id) REFERENCES leaderboard(id),
FOREIGN KEY (prev_id) REFERENCES leaderboard(id),
id BYTEA NOT NULL,
authoritative BOOLEAN DEFAULT FALSE,
sort_order SMALLINT DEFAULT 1 NOT NULL, -- asc(0), desc(1)
count BIGINT DEFAULT 0 CHECK (count >= 0) NOT NULL,
reset_schedule VARCHAR(64), -- e.g. cron format: "* * * * * * *"
metadata BYTEA DEFAULT '{}' CHECK (length(metadata) < 16000) NOT NULL,
next_id BYTEA DEFAULT NULL::BYTEA CHECK (next_id <> id),
prev_id BYTEA DEFAULT NULL::BYTEA CHECK (prev_id <> id)
);

CREATE TABLE IF NOT EXISTS leaderboard_record (
PRIMARY KEY (leaderboard_id, expires_at, owner_id),
-- Creating a foreign key constraint and defining indexes that include it
-- in the same transaction breaks. See issue cockroachdb/cockroach#13505.
-- In this case we prefer the indexes over the constraint.
-- FOREIGN KEY (leaderboard_id) REFERENCES leaderboard(id),
id BYTEA UNIQUE NOT NULL,
leaderboard_id BYTEA NOT NULL,
owner_id BYTEA NOT NULL,
handle VARCHAR(20) NOT NULL,
lang VARCHAR(18) DEFAULT 'en' NOT NULL,
location VARCHAR(64), -- e.g. "San Francisco, CA"
timezone VARCHAR(64), -- e.g. "Pacific Time (US & Canada)"
rank_value BIGINT DEFAULT 0 CHECK (rank_value >= 0) NOT NULL,
score BIGINT DEFAULT 0 NOT NULL,
num_score INT DEFAULT 0 CHECK (num_score >= 0) NOT NULL,
-- FIXME replace with JSONB
metadata BYTEA DEFAULT '{}' CHECK (length(metadata) < 16000) NOT NULL,
ranked_at INT CHECK (ranked_at >= 0) DEFAULT 0 NOT NULL,
updated_at INT CHECK (updated_at > 0) NOT NULL,
-- Used to enable proper order in revscan when sorting by score descending.
updated_at_inverse INT CHECK (updated_at > 0) NOT NULL,
expires_at INT CHECK (expires_at >= 0) DEFAULT 0 NOT NULL,
banned_at INT CHECK (expires_at >= 0) DEFAULT 0 NOT NULL
);
CREATE INDEX IF NOT EXISTS owner_id_leaderboard_id_idx ON leaderboard_record (owner_id, leaderboard_id);
CREATE INDEX IF NOT EXISTS leaderboard_id_expires_at_score_updated_at_inverse_id_idx ON leaderboard_record (leaderboard_id, expires_at, score, updated_at_inverse, id);
CREATE INDEX IF NOT EXISTS leaderboard_id_expires_at_score_updated_at_id_idx ON leaderboard_record (leaderboard_id, expires_at, score, updated_at, id);
CREATE INDEX IF NOT EXISTS leaderboard_id_expires_at_lang_score_updated_at_inverse_id_idx ON leaderboard_record (leaderboard_id, expires_at, lang, score, updated_at_inverse, id);
CREATE INDEX IF NOT EXISTS leaderboard_id_expires_at_lang_score_updated_at_id_idx ON leaderboard_record (leaderboard_id, expires_at, lang, score, updated_at, id);
CREATE INDEX IF NOT EXISTS leaderboard_id_expires_at_location_score_updated_at_inverse_id_idx ON leaderboard_record (leaderboard_id, expires_at, location, score, updated_at_inverse, id);
CREATE INDEX IF NOT EXISTS leaderboard_id_expires_at_location_score_updated_at_id_idx ON leaderboard_record (leaderboard_id, expires_at, location, score, updated_at, id);
CREATE INDEX IF NOT EXISTS leaderboard_id_expires_at_timezone_score_updated_at_inverse_id_idx ON leaderboard_record (leaderboard_id, expires_at, timezone, score, updated_at_inverse, id);
CREATE INDEX IF NOT EXISTS leaderboard_id_expires_at_timezone_score_updated_at_id_idx ON leaderboard_record (leaderboard_id, expires_at, timezone, score, updated_at, id);

-- +migrate Down
DROP TABLE IF EXISTS leaderboard_record;
DROP TABLE IF EXISTS leaderboard CASCADE;
Loading

0 comments on commit 14cbcc7

Please sign in to comment.