package store import ( "bytes" "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/gob" "encoding/hex" "errors" "fmt" "wh/sim" ) // Ship statuses. const ( StatusInventory = "inventory" // built, can be programmed StatusLaunching = "launching" // enters the belt on the next run StatusActive = "active" // in the belt StatusDestroyed = "destroyed" ) var ( ErrNotFound = errors.New("not found") ErrState = errors.New("ship is not in the right state for that") ErrTaken = errors.New("name already taken") ) type Player struct { ID int64 Name string Credits int64 } type Ship struct { ID int64 OwnerID int64 Status string ProgramSize int DownlinkDay int64 // -1 if none yet } func HashKey(key string) string { h := sha256.Sum256([]byte(key)) return hex.EncodeToString(h[:]) } // Register creates a player with one command ship in inventory and returns // the player's API key (shown once; only its hash is stored). func (s *Store) Register(ctx context.Context, name string) (Player, string, int64, error) { var ( p Player shipID int64 key string ) b := make([]byte, 24) if _, err := rand.Read(b); err != nil { return p, "", 0, err } key = hex.EncodeToString(b) err := s.WithTx(ctx, func(q *Q) error { var n int if err := q.row(ctx, `SELECT COUNT(*) FROM players WHERE name = ?`, name).Scan(&n); err != nil { return err } if n > 0 { return ErrTaken } if err := q.row(ctx, `INSERT INTO players (name, key_hash) VALUES (?, ?) RETURNING id`, name, HashKey(key)).Scan(&p.ID); err != nil { return err } p.Name = name return q.row(ctx, `INSERT INTO ships (owner_id, status) VALUES (?, ?) RETURNING id`, p.ID, StatusInventory).Scan(&shipID) }) return p, key, shipID, err } func (q *Q) PlayerByKey(ctx context.Context, key string) (Player, error) { var p Player err := q.row(ctx, `SELECT id, name, credits FROM players WHERE key_hash = ?`, HashKey(key)). Scan(&p.ID, &p.Name, &p.Credits) if err == sql.ErrNoRows { return p, ErrNotFound } return p, err } func (q *Q) PlayerShips(ctx context.Context, owner int64) ([]Ship, error) { rows, err := q.query(ctx, `SELECT id, owner_id, status, COALESCE(LENGTH(program), 0), downlink_day FROM ships WHERE owner_id = ? ORDER BY id`, owner) if err != nil { return nil, err } defer rows.Close() var out []Ship for rows.Next() { var sh Ship if err := rows.Scan(&sh.ID, &sh.OwnerID, &sh.Status, &sh.ProgramSize, &sh.DownlinkDay); err != nil { return nil, err } out = append(out, sh) } return out, rows.Err() } // ownedStatus returns the ship's status if owner owns it. func (q *Q) ownedStatus(ctx context.Context, owner, ship int64) (string, error) { var st string err := q.row(ctx, `SELECT status FROM ships WHERE id = ? AND owner_id = ?`, ship, owner).Scan(&st) if err == sql.ErrNoRows { return "", ErrNotFound } return st, err } func (q *Q) requireOneRow(res sql.Result, err error) error { if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrState } return nil } // SetProgram replaces the program of a ship that is still in inventory. func (q *Q) SetProgram(ctx context.Context, owner, ship int64, prog []byte) error { if _, err := q.ownedStatus(ctx, owner, ship); err != nil { return err } res, err := q.exec(ctx, `UPDATE ships SET program = ? WHERE id = ? AND owner_id = ? AND status = ?`, prog, ship, owner, StatusInventory) return q.requireOneRow(res, err) } // Launch queues an inventory ship (which must have a program) to enter the // belt on the next daily run. func (q *Q) Launch(ctx context.Context, owner, ship int64) error { if _, err := q.ownedStatus(ctx, owner, ship); err != nil { return err } res, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE id = ? AND owner_id = ? AND status = ? AND program IS NOT NULL`, StatusLaunching, ship, owner, StatusInventory) return q.requireOneRow(res, err) } // SetUplink stores the message delivered on the next run, replacing any // message already queued. func (q *Q) SetUplink(ctx context.Context, owner, ship int64, data []byte) error { if _, err := q.ownedStatus(ctx, owner, ship); err != nil { return err } res, err := q.exec(ctx, `UPDATE ships SET uplink = ? WHERE id = ? AND owner_id = ? AND status IN (?, ?)`, data, ship, owner, StatusActive, StatusLaunching) return q.requireOneRow(res, err) } // Downlink returns the latest downlink and the day it was produced. func (q *Q) Downlink(ctx context.Context, owner, ship int64) ([]byte, int64, error) { var ( data []byte day int64 ) err := q.row(ctx, `SELECT downlink, downlink_day FROM ships WHERE id = ? AND owner_id = ?`, ship, owner). Scan(&data, &day) if err == sql.ErrNoRows { return nil, 0, ErrNotFound } return data, day, err } // PendingLaunches returns ships due to launch, ordered by id. func (q *Q) PendingLaunches(ctx context.Context) ([]sim.Launch, error) { rows, err := q.query(ctx, `SELECT id, owner_id, program FROM ships WHERE status = ? ORDER BY id`, StatusLaunching) if err != nil { return nil, err } defer rows.Close() var out []sim.Launch for rows.Next() { var l sim.Launch if err := rows.Scan(&l.ShipID, &l.Owner, &l.Program); err != nil { return nil, err } out = append(out, l) } return out, rows.Err() } // PendingUplinks returns queued uplinks, ordered by ship id. func (q *Q) PendingUplinks(ctx context.Context) ([]sim.Uplink, error) { rows, err := q.query(ctx, `SELECT id, uplink FROM ships WHERE uplink IS NOT NULL ORDER BY id`) if err != nil { return nil, err } defer rows.Close() var out []sim.Uplink for rows.Next() { var u sim.Uplink if err := rows.Scan(&u.ShipID, &u.Data); err != nil { return nil, err } out = append(out, u) } return out, rows.Err() } // LoadState returns the most recent world snapshot, or nil if none exists. func (q *Q) LoadState(ctx context.Context) (*sim.State, error) { var data []byte err := q.row(ctx, `SELECT data FROM snapshots ORDER BY day DESC LIMIT 1`).Scan(&data) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } var st sim.State if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&st); err != nil { return nil, fmt.Errorf("decode snapshot: %w", err) } return &st, nil } func (q *Q) SaveState(ctx context.Context, st *sim.State) error { var buf bytes.Buffer if err := gob.NewEncoder(&buf).Encode(st); err != nil { return err } h := st.Hash() _, err := q.exec(ctx, `INSERT INTO snapshots (day, hash, data) VALUES (?, ?, ?)`, st.Day, hex.EncodeToString(h[:]), buf.Bytes()) return err } // RecordRun writes the outputs of a completed day. func (q *Q) RecordRun(ctx context.Context, res sim.DayResult, ranAt string) error { h := res.Hash if _, err := q.exec(ctx, `INSERT INTO day_runs (day, hash, ran_at) VALUES (?, ?, ?)`, res.Day, hex.EncodeToString(h[:]), ranAt); err != nil { return err } for _, e := range res.Events { if _, err := q.exec(ctx, `INSERT INTO events (day, tick, ship_id, kind, detail) VALUES (?, ?, ?, ?, ?)`, res.Day, e.Tick, e.ShipID, e.Kind, e.Detail); err != nil { return err } } for _, d := range res.Downlinks { if _, err := q.exec(ctx, `UPDATE ships SET downlink = ?, downlink_day = ? WHERE id = ?`, d.Data, res.Day, d.ShipID); err != nil { return err } } return nil } // AfterRun clears delivered uplinks, activates launched ships, marks // destroyed ones and updates player balances. func (q *Q) AfterRun(ctx context.Context, st *sim.State) error { if _, err := q.exec(ctx, `UPDATE ships SET uplink = NULL WHERE uplink IS NOT NULL`); err != nil { return err } if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE status = ?`, StatusActive, StatusLaunching); err != nil { return err } for _, sh := range st.Ships { if !sh.Alive { if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE id = ?`, StatusDestroyed, sh.ID); err != nil { return err } } } for owner, credits := range st.Credits { if _, err := q.exec(ctx, `UPDATE players SET credits = ? WHERE id = ?`, credits, owner); err != nil { return err } } return nil }