Nuvaka › Geliştirici belgeleri › Uzantı sunucuları

Uzantı sunucuları

Bir uzantı paketinde sunucu kodu taşıyabilir. Kod, Nuvaka'nın ayrı bir makinesinde sürekli çalışan bir sunucu örneği olarak açılır; kullanıcılar uygulamanın içinden bu sunuculara katılır. Kararı sunucu verdiği için istemciye güvenmek gerekmez.

Sürüm: Nuvaka 0.3.1 ve geliştirici paketinde SDK 1.3.0 ile gelir; 1.3.1 önerilir (sahte sunucu ve init --server şablonu aşağıdaki katılma sırasına uyar). Manifestte "minAppVersion": "0.3.1" yaz.

Kullanım örnekleri:

  • 7/24 açık oyun dünyası;
  • sosyal uygulama arka ucu;
  • hile yapılamayan, kararı sunucunun verdiği oyun;
  • bir kullanıcının açtığı maç sunucusu.

Onaydan geçmeyen kod hiç çalışmaz. Sunucu dışarıya açık bir site olamaz: oyuncular yalnız Nuvaka hesabıyla, uygulamanın içinden gelir. Sunucusuz, yalnız aktaran odalar için: Gerçek zamanlı odalar.

Hızlı başlangıç: yayıncı (sürekli) sunucusu

  1. İskeleti oluştur
    nuvaka-ext init oyun --username kullanici-adin --server
    # ya da: npm create @nuvaka/extension -- --server

    Şablon server/main.js (tıklamaları sayan ve SQLite'ta saklayan bir sunucu), onu kullanan bir arayüz ve bir test üretir; manifeste server bölümünü (ör. { "entry": "server/main.js", "tickHz": 5, "maxPlayers": 100 }) ve nuvaka.realtime iznini ekler. Şablon SDK ^1.3.0 ister. --server arayüzü de kurar; veri eklentisiyle (--kind data) kullanılamaz.

  2. Sunucu kodunu yaz ve yerelde dene

    server/main.js içinde nuvaka.on('start' | 'join' | 'message' | 'tick' | 'leave' | 'stop', …) işleyicilerini yaz (Sunucu kodu). SDK'nın sahte sunucusuyla test et: nuvaka-ext test (Test).

  3. Yayınla
    nuvaka-ext publish --changelog "Sunucu"

    server olan her sürüm incelemeye düşer; onaylanınca yayına girer. Sunucular her zaman uzantının yayındaki sürümünün kodunu çalıştırır.

  4. Sunucuyu başlat

    Onaydan sonra sunucuyu tanımla. Uygulamada: Profil › Geliştirici › Sunucular › uzantını seç › Yeni sunucu (ayrıntı). Ya da API ile, kullanıcı token'ı veya nvd_ geliştirici token'ıyla:

    POST https://generalappapi.nuvaka.com/api/ext/v1/dev/extensions/<id>/servers
    Authorization: Bearer nvd_xxxxxxxx
    Content-Type: application/json
    
    { "name": "Ana dünya", "visibility": "public", "maxPlayers": 100 }
    
    → { "success": true, "id": "<serverId>", "started": true, "error": null }

    started: false gelirse sebep error alanındadır; loglar panelde Loglar düğmesinde ya da GET …/servers/<serverId>/logs ucundadır.

  5. Gerisini gözetmen yapar

    Sunucu bundan sonra sürekli açık kalır: çökerse ya da yeni sürüm onaylanırsa gözetmen 20 sn içinde yeniden başlatır. Durum GET …/servers ile görülür (status, players, limits); ayarlar PATCH …/servers/<id> ile değişir, POST …/<id>/restart yeniden başlatır, DELETE siler (Yayıncı API'si).

  6. Uygulama tarafı

    nuvaka.servers.list() listeyi getirir; join(serverId, password?) ile katılır, send ile yazar, onMessage ile dinlersin (İstemci API'si).

Manifest

"server": { "entry": "server/main.js", "tickHz": 5, "maxPlayers": 16, "memoryMB": 64, "userServers": true },
"permissions": [{ "nuvaka.realtime": true, "reason": { "tr": "Çok oyunculu", "en": "Multiplayer" } }]
AlanKural
entryzorunlu; paketteki .js ya da .mjs dosyası (en çok 200 karakter). Arayüz dosyası (entry.ui) olamaz. Sunucu kodu da normal taramadan geçer (eval, new Function … yasak).
tickHz0–20 tam sayı; 0 = tick yok
maxPlayers1–1000 tam sayı; varsayılan 16. Her sunucunun oyuncu sınırı bunu aşamaz.
memoryMB16–128 tam sayı; varsayılan 64. Her örneğin bellek sınırı.
userServerstrue/false. true ise kullanıcılar da uygulamadan sunucu başlatabilir; yoksa yalnız yayıncının sürekli sunucuları çalışır.
  • nuvaka.realtime izni zorunludur, çünkü oyuncular sunucuya gerçek zamanlı kanaldan bağlanır (yoksa: "server için nuvaka.realtime izni gerekli").
  • Bilinmeyen alan reddedilir. Veri eklentisinde server olamaz.
  • server olan her sürüm Nuvaka incelemesine düşer (İnceleme).

Sunucu kodu

Sunucu kodu yalıtılmış V8'de (isolated-vm) klasik betik olarak çalışır. require, import/export, process, fetch, zamanlayıcı (setTimeout/setInterval), dosya sistemi ve ağ yoktur; kodun tek dosya olmalı (gerekirse paketleyiciyle birleştir). Dünyaya yalnız global nuvaka nesnesiyle dokunur. nuvaka-ext lint sunucu kodunda bunları görürse uyarır.

Tipler @nuvaka/extension-sdk/server modülündedir (yalnız tip; çalışma zamanı kodu yok):

/** @type {import('@nuvaka/extension-sdk/server').ServerNuvaka} */
const nv = globalThis.nuvaka

Olaylar

nuvaka.on(olay, fn) ile dinlenir. Her olayın tek işleyicisi vardır; sonraki on öncekinin yerine geçer.

OlayNe zamanSüre sınırı
start()sunucu açılırken2 sn
join(player)oyuncu katılırken; false ya da metin (sebep) döndürürse reddeder, başka her dönüş kabul eder200 ms
leave(player)oyuncu ayrılırken ya da bağlantısı düşünce200 ms
message(player, data)oyuncudan mesaj gelince50 ms
tick(dtMs)tickHz sıklığında; dtMs önceki tick'ten beri geçen süre50 ms
stop()sunucu kapanırken1 sn

player = { username, displayName }. Profili gizli oyuncuda displayName null'dır; leave'de gelmeyebilir.

Katılma sırası

Oyuncu, join işleyicisi çağrılmadan önce players() listesine eklenir. Bu yüzden join içinde send(player.username, …) ve broadcast katılana da ulaşır: ilk durumu ona doğrudan burada gönder. İşleyici reddederse oyuncu listeden çıkarılır.

nv.on('join', (player) => {
  if (isBanned(player.username)) return 'Bu sunucudan uzaklaştırıldın'   // ret; sebep oyuncuya gider
  nv.send(player.username, { t: 'hello', world: snapshot() })            // katılana ulaşır
  nv.broadcast({ t: 'joined', username: player.username }, player.username)
  return true
})

Araçlar

AraçAçıklama
send(username, data)tek oyuncuya; oyuncuda server.message olur. data ≤ 16 KB JSON.
broadcast(data, exceptUsername?)herkese (isteğe bağlı biri hariç); ≤ 16 KB
kick(username, reason?)oyuncuyu atar; oyuncuda server.kicked { reason }
players()bağlı oyuncular: [{ username, displayName }]
setInfo(obj)sunucu listesinde görünen bilgi (harita, mod …); ≤ 1 KB JSON
log(...args)yayıncının log ekranına düşer; son 200 satır, satır başına en çok 2000 karakter
instance{ id, name, maxPlayers, extensionId, version }
dbuzantı başına SQLite (aşağıda)

Hatalar ve çökme

  • Süre aşımı ya da işleyicide atılan hata sunucuyu düşürmez; hata log'a yazılır.
  • Bellek sınırı (memoryMB) aşılırsa ya da 200 hatadan sonra örnek kapanır. Yayıncı sunucusu 20 sn içinde yeniden açılır.
  • Sunucu kodu ağ kullanamaz, oyunculardan gelen veriyi de doğrulamadan kullanma: message'daki her alanı denetle.

nuvaka.db: uzantı başına SQLite

Her uzantının sunucu tarafında kendi SQLite veritabanı vardır; uzantının tüm sunucu örnekleri aynı veritabanını kullanır. Çağrılar senkrondur.

MetotDönüş
exec(sql)null; birden çok deyim (şema, göç)
run(sql, ...params){ changes, lastInsertRowid }
get(sql, ...params)ilk satır ya da null
all(sql, ...params)satırlar; en çok 5000
  • Parametreler ? ile verilir. Nesne ve dizi JSON metni olarak saklanır; undefined NULL olur.
  • ATTACH, DETACH, load_extension, VACUUM INTO ve PRAGMA'lar yasaktır; yalnız table_info, index_list, foreign_key_list ve user_version okunabilir.
  • Kota: 50 MB + kurulum sayısı / 10 MB, en çok 10 GB; yönetici ek alan tanıyabilir (aşağıda). Veritabanı ortak ve küçük durum içindir; kullanıcıya ait büyük veri için aşağıya bak.
  • Sunucu silinince veritabanı silinmez.
nv.on('start', () => {
  nv.db.exec('CREATE TABLE IF NOT EXISTS wins (username TEXT PRIMARY KEY, wins INTEGER NOT NULL DEFAULT 0)')
})
nv.db.run('INSERT INTO wins (username, wins) VALUES (?, 1) ON CONFLICT (username) DO UPDATE SET wins = wins + 1', player.username)
const top = nv.db.all('SELECT username, wins FROM wins ORDER BY wins DESC LIMIT 10')

Büyük ya da kişisel veri: veritabanına değil kullanıcının depolamasına

Sunucu veritabanı ortak ve küçük durum içindir: lobi, skor tablosu, eşleştirme, oda ve liste bilgisi. Kullanıcıya ait büyük veri (dosyalar, fotoğraflar, uzun geçmiş) sunucu veritabanına konmaz; kullanıcının kendi Nuvaka depolamasında durur:

  • Uygulama alanı (nuvaka.storage): her kullanıcıda uzantıya ayrılmış alan; manifest storage.quotaMB en çok 100. İzin gerekmez, cihazlar arası eşitlenir.
  • Dosyalarım (nuvaka.cloud): kullanıcının uzantıya açtığı klasörler, kullanıcının kendi kotasıyla. Büyük veri için budur. Yazmak için nuvaka.cloud: write gerekir; bu kritik izin her kullanımda sorulur ve isteyen her sürüm incelemeye düşer.
  • Şifreleme: hassas veriyi istemcide şifrele (WebCrypto, ör. AES-GCM; anahtar kullanıcıdan ya da uzantının yerel alanından) ve encrypted: true ile yükle. Nuvaka yalnız şifreli bayt görür; şifreli işaretli dosyanın önizlemesi üretilmez, asistan okumaz.
  • Sunucu veritabanında yalnız başvuru tut: hangi kullanıcının hangi dosyası (fileId). Büyük içerik orada durmaz.
// Arayüz: istemcide AES-GCM ile şifrele, Dosyalarım'a şifreli yükle, sunucuya yalnız fileId gönder
async function saveEncrypted(folderId, name, bytes, key) {    // key: WebCrypto AES-GCM CryptoKey
  const iv = crypto.getRandomValues(new Uint8Array(12))
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, bytes))
  const blob = new Uint8Array(iv.length + ct.length)
  blob.set(iv)
  blob.set(ct, iv.length)
  const { file } = await nuvaka.cloud.upload(folderId, name, blob, 'application/octet-stream', { encrypted: true })
  await nuvaka.servers.send(serverId, { t: 'attach', fileId: file.fileId })
  return file.fileId
}

async function loadEncrypted(fileId, key) {
  const blob = await nuvaka.cloud.read(fileId)                  // Uint8Array
  const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: blob.slice(0, 12) }, key, blob.slice(12))
  return new Uint8Array(plain)
}
// server/main.js: yalnız başvuruyu sakla
nv.on('message', (player, data) => {
  if (data?.t === 'attach' && typeof data.fileId === 'string' && data.fileId.length <= 100)
    nv.db.run('INSERT INTO attachments (username, file_id) VALUES (?, ?)', player.username, data.fileId)
})

Sunucu türleri

Yayıncı sunucusu (sürekli)Kullanıcı sunucusu
Kim açarYayıncı: uygulamada Profil › Geliştirici › Sunucular ya da APIKullanıcı, uygulamanın içinden (nuvaka.servers.create); manifestte userServers: true gerekir
ÖmürGözetmen ayakta tutar, çökünce yeniden açar; yeni onaylı sürüm çıkınca yeniden başlatıp ona geçirirKimse bağlı değilken 30 dk sonra, en geç 12 saatte kapanır
Sınır3 + kurulum/100, en çok 20Kişi başına aynı anda 2; uzantı başına aynı anda 100 + kurulum/2, en çok 2000
Listedeönce; düşmüşken de (stopped/failed) görünüryalnız çalışırken

Durum (status)

DeğerAnlamı
runningçalışıyor
stoppeddurdu; yayıncı sunucusunu gözetmen yeniden açar
failedbaşlatılamadı ya da çöktü; sebep lastError'da
disabledyayıncı durdurdu; gözetmen açmaz

Başka bir değer gelirse "bekliyor" olarak göster.

Görünürlük

DeğerKim katılabilir
publicuygulamayı kullanan herkes (varsayılan)
friendssunucu sahibinin arkadaşları
passwordherkes görür, katılmak için şifre gerekir (4–100 karakter; bcrypt ile saklanır)
inviteyalnız sahibinin davet ettiği arkadaşlar

Sunucu sahibi şifre sorulmadan girer. Şifre değiştirme: görünürlük zaten password ise PATCH { password } yalnız şifreyi değiştirir; PATCH { visibility: "password", password } sunucuyu şifreli yapar; başka bir görünürlüğe geçilince şifre silinir.

Uygulamada yönetim: Profil › Geliştirici › Sunucular

  • Uzantını seç; yayıncı sunucuları ve kullanıcıların başlattığı sunucular birlikte listelenir (tür, durum, oyuncu sayısı, başlama zamanı).
  • Yeni sunucu: ad (1–60 karakter), görünürlük, şifre, en çok oyuncu (server.maxPlayers'ı aşamaz). Oluşturulunca başlatılır.
  • Düzenle: aynı alanlar ve "Durdurulmuş" (gözetmen açmaz). Şifre alanını boş bırakırsan şifre değişmez.
  • Yeniden başlat, Sil (bağlı oyuncular çıkarılır; veritabanı silinmez) ve Loglar (log(...) çıktısı ve yakalanan hatalar, son 200 satır, bellekte).
  • Üstte kotalar görünür: sürekli sunucu, açık kullanıcı sunucusu, kişi başına sınır, veritabanı kotası. Çalıştırıcıya ulaşılamıyorsa sunucu başlatılamaz; biraz sonra tekrar dene.

Yayıncı API'si

Kullanıcı token'ı ya da nvd_ geliştirici token'ı ile, /api/ext/v1/dev/extensions/{id}/servers:

UçAçıklama
GET{ items: [{ id, kind, name, visibility, maxPlayers, version, status, lastError, info, players, createdAt, startedAt }], limits, runnerAvailable }
POST { name, visibility?, password?, maxPlayers? }{ id, started, error }. Kota doluysa 429 quota; yayındaki sürümde server yoksa 400 no_server_code.
PATCH /{sid} { name?, visibility?, password?, maxPlayers?, disabled? }düzenle; şifre kuralları yukarıda
POST /{sid}/restartyeniden başlat
DELETE /{sid}sil
GET /{sid}/logsson 200 log satırı

İstemci API'si: nuvaka.servers

nuvaka.realtime izni gerekir. Gizli kopyalar kullanamaz (not_for_copies); güvenli oturumda kapalıdır.

ÇağrıNeredeDönüş
list()ikisikatılabileceğin sunucular; yayıncınınkiler önce, sonra oyuncu sayısına göre
create({ name, visibility?, password?, maxPlayers? })ikisi{ success, serverId }; kullanıcı sunucusu başlatır. Katılmak için ardından join.
delete(serverId)ikisiyalnız kendi başlattığın sunucuyu kapatır; bağlı herkese server.closed (closed_by_owner)
invite(serverId, username)ikisiyalnız sahibi, yalnız arkadaşını davet eder
join(serverId, password?)arayüz{ joined, serverId, info, username }; username senin kullanıcı adın
send(serverId, data)arayüznull; sunucu message(player, data) ile alır
leave(serverId)arayüznull; katılmamışsan etkisiz
invited()arayüzdavet bağlantısıyla açıldıysa sunucu kimliği (ctx.server), değilse null

list() öğesi: { serverId, kind: 'publisher' | 'user', name, visibility, needsPassword, owner, mine, status, players, maxPlayers, info, createdAt }. needsPassword şifreli ve senin olmayan sunucuda true'dur; owner yayıncı sunucusunda null; info sunucu kodunun setInfo ile verdiği bilgidir. Liste yalnız katılabileceklerini içerir: friends sunucusunu sahibinin arkadaşları, invite sunucusunu davetliler görür (sahibi her zaman görür).

create kuralları: name 1–60 karakter; visibility varsayılan public; password yalnız password görünürlükte, 4–100 karakter; maxPlayers manifestteki server.maxPlayers'ı aşarsa kırpılır.

Olaylar

OlayKısayolYük
server.messageonMessage{ serverId, data } — sunucu kodunun send/broadcast'i
server.kickedonKicked{ serverId, reason }
server.closedonClosed{ serverId, reason } — sahibi kapattı (closed_by_owner), yayıncı sildi/durdurdu/yeniden başlattı, çöktü, boşta kaldı ya da bağlantı dönünce yeniden katılım olmadı (reason = hata kodu)
server.inviteonInvite{ serverId } — uygulama açıkken sunucu davetine tıklandı
realtime.statusonStatus{ connected, reconnecting?, error? } — gerçek zamanlı odalarla ortak bağlantı

Bağlantı koparsa kabuk yeniden bağlanır ve sunucuya kendiliğinden yeniden katılır; olmazsa server.closed { reason: <hata kodu> } gelir.

Davet ve bağlantı

Sunucu sahibi invite(serverId, username) ile arkadaşını davet eder. Davetli kendi dilinde bir ext_server_invite bildirimi alır; bağlantı /apps?id=<uzantı>&server=<serverId> biçimindedir. Tıklayınca uygulaman açılır ve bağlamda ctx.server gelir (nuvaka.servers.invited()); uygulama zaten açıksa onInvite çalışır. invite görünürlüğündeki sunucuya yalnız böyle davet edilenler girebilir.

Lobi deseni

Nuvaka hazır bir sunucu seçme penceresi göstermez: info (harita, mod …) uygulamana özgüdür, lobinin görünüşü de senin tasarımına aittir. Listeyi çiz, çalışmayanları soluk göster, şifreli sunucuda şifreyi kendi pencerende sor.

Hata kodları

KodAnlamı
server_not_foundsunucu yok
server_not_runningsunucu çalışmıyor
friends_onlyyalnız sahibinin arkadaşları katılabilir
bad_passwordşifre yanlış ya da verilmedi (create'te: şifre 4–100 karakter değil)
invite_onlyyalnız davetliler katılabilir
server_fullsunucu dolu
rejectedsunucu kodunun join işleyicisi reddetti; sebep e.data.reason
server_unavailablesunucu çalıştırıcısına ulaşılamıyor
not_joinedsunucuya katılmadan send
message_too_largemesaj 16 KB'ı aşıyor
rate_limitedsaniyede 20 mesaj sınırı aşıldı
user_servers_disabled(403) manifestte userServers yok
too_many_servers(429) kişi başına aynı anda 2 kullanıcı sunucusu
extension_quota(429) uzantının açık kullanıcı sunucusu kotası dolu
start_failed(502) sunucu başlatılamadı
not_owneryalnız sunucunun sahibi kapatabilir ya da davet edebilir
not_friendyalnız arkadaş davet edilebilir
bad_name, bad_visibilityad 1–60 karakter değil / görünürlük geçersiz
not_for_copies, secure_session, ui_onlygizli kopya / güvenli oturum / arka planda join, send, leave

Sınırlar

Sınır
tickHz0–20
maxPlayers (sunucu başına oyuncu)1–1000 (varsayılan 16)
memoryMB16–128 (varsayılan 64)
Mesaj (iki yönde)≤ 16 KB JSON
Oyuncunun gönderimisaniyede 20
setInfo≤ 1 KB
Logson 200 satır
İşleyici süreleristart 2 sn, join/leave 200 ms, message/tick 50 ms, stop 1 sn
Yayıncı sunucusu3 + kurulum/100, en çok 20
Kullanıcı sunucusu (uzantı başına, aynı anda)100 + kurulum/2, en çok 2000
Kişi başına kullanıcı sunucusu2
Veritabanı50 MB + kurulum/10, en çok 10 GB; all ≤ 5000 satır

Kotalarda küçük sabit sınır yoktur; kurulum sayısıyla büyür. Asıl sınır makinenin kaynağıdır ve gözetmen izler. Güncel değerler yayıncı API'sinin GET …/servers yanıtındaki limits alanında döner (izin verilen maxPlayers dahil).

Daha fazla veritabanı alanı: Nuvaka yöneticisi bir uzantıya ek sunucu veritabanı alanı tanıyabilir; çalışan sunucular yeniden başlayınca geçerli olur. İhtiyacın varsa uzantının kimliği ve gerekçeyle [email protected] adresine yaz. Önce aşağıdaki büyük veri bölümüne bak: kullanıcıya ait veri zaten veritabanına girmemeli.

İnceleme

  • server olan her sürüm Nuvaka incelemesine düşer; yayıncı onaylı olsa ya da ilk sürümü olmasa da. Sunucu kodu Nuvaka'nın makinesinde çalıştığı için bu kural gevşemez.
  • Onaylanana kadar sunucular eski sürümde kalır. Yeni sürüm onaylanınca gözetmen yayıncı sunucularını yeniden başlatıp yeni sürüme geçirir; bağlı oyunculara server.closed gider, istemcin lobiye dönüp yeniden katılmalı.

Güvenlik ve gizlilik

  • Sunucu kodu Nuvaka'nın ayrı bir makinesinde, yetkisiz bir serviste çalışır; her örnek kendi isolate'inde, kendi bellek sınırıyla açılır.
  • Nuvaka API'si ile çalıştırıcı arasındaki trafik TLS ile şifrelidir ve her istek imzalıdır. Paket çalıştırıcıya özet doğrulamasıyla yüklenir.
  • Oyuncular sunucu koduna yalnız kullanıcı adı ve görünen adıyla gelir; e-posta ve kullanıcı kimliği verilmez.

Test: sahte nuvaka

@nuvaka/extension-sdk/testing Nuvaka API'sini ve çalıştırıcıyı bellekte taklit eder; sunucu kodun süreç içinde gerçekten çalışır. Görünürlük, kişi başına 2 sunucu, doluluk, rejected ve gönderim hızı kuralları uygulanır.

AraçAçıklama
servers: { script, userServers, maxPlayers, tickHz, db, invitedServer }seçenek. script: (nv) => { … } sunucu kodu, her örnek açılışta sahte ServerNuvaka ile çalışır. db: nuvaka.db uygulaması (yoksa çağrılar unsupported atar). Kullanıcı adı ve arkadaşlar realtime seçeneğinden.
m.serverHost.create({ name, kind?, owner?, visibility?, password?, maxPlayers?, script? })sunucu açar (varsayılan: herkese açık yayıncı sunucusu); kimliği döner
m.serverHost.join(id, username)başka oyuncu katılır: kabulde true, redde sebep ya da false
m.serverHost.send / leave / tick / stop / invitebaşka oyuncunun mesajı, ayrılışı, bir tick (varsayılan 1000 / tickHz ms), sunucunun kapanması, bu kullanıcıyı davet
m.serverHost.server(id)içi: players, info, logs, errors, outbox
m.log.servers, m.log.serverInvitessunucuya gönderilenler ve davetler

nuvaka.db için kendi uygulamanı ver; ör. node:sqlite üstüne küçük bir sarmalayıcı. server/main.js klasik betik olduğundan yüklenirken globalThis.nuvaka'yı okur; init --server şablonundaki gibi işleyicileri bir vekille toplayıp her sahte örneğe bağla (örnekte).

Tam örnek: sayı tahmini

Sunucu 1–100 arası bir sayı tutar; oyuncular tahmin eder, sunucu "yukarı/aşağı" der. İlk bilen kazanır, kazanç SQLite'a yazılır; tur 60 saniyede biter. Sayıyı yalnız sunucu bilir, bu yüzden istemci hile yapamaz.

nuvaka.json

{
  "id": "kullanici-adin.tahmin",
  "name": "Sayı tahmini",
  "version": "1.0.0",
  "apiVersion": 1,
  "minAppVersion": "0.3.1",
  "description": { "tr": "1–100 arası sayıyı ilk bilen kazanır", "en": "First to guess the number between 1 and 100 wins" },
  "storeCategory": "games",
  "icon": "icon.svg",
  "entry": { "ui": "index.html" },
  "pages": [{ "id": "main", "title": { "tr": "Sayı tahmini", "en": "Number guess" } }],
  "permissions": [
    { "nuvaka.realtime": true, "reason": { "tr": "Oyun sunucusuna bağlanmak için", "en": "To connect to the game server" } }
  ],
  "server": { "entry": "server/main.js", "tickHz": 1, "maxPlayers": 32, "memoryMB": 32, "userServers": true }
}

server/main.js

// Sayı tahmini: kararı sunucu verir. Klasik betik; import/require, fetch, zamanlayıcı yok.
/** @type {import('@nuvaka/extension-sdk/server').ServerNuvaka} */
const nv = globalThis.nuvaka
const ROUND_MS = 60_000
let secret = 0
let left = ROUND_MS
let round = 0

function newRound() {
  secret = 1 + Math.floor(Math.random() * 100)
  left = ROUND_MS
  round++
  nv.setInfo({ round })                                   // sunucu listesinde görünür
  nv.broadcast({ t: 'round', round, seconds: ROUND_MS / 1000 })
}
const top = () => nv.db.all('SELECT username, wins FROM wins ORDER BY wins DESC LIMIT 10')

nv.on('start', () => {
  nv.db.exec(`
    CREATE TABLE IF NOT EXISTS wins (username TEXT PRIMARY KEY, wins INTEGER NOT NULL DEFAULT 0);
    CREATE TABLE IF NOT EXISTS bans (username TEXT PRIMARY KEY);
  `)
  newRound()
  nv.log('açıldı:', nv.instance.name, nv.instance.version)
})

nv.on('join', (player) => {
  if (nv.db.get('SELECT 1 AS x FROM bans WHERE username = ?', player.username)) return 'Bu sunucudan uzaklaştırıldın'
  // Oyuncu listeye işleyiciden önce girer: ilk durumu doğrudan ona gönder
  nv.send(player.username, { t: 'hello', round, secondsLeft: Math.ceil(left / 1000), top: top() })
  nv.broadcast({ t: 'joined', username: player.username }, player.username)
  return true
})

nv.on('message', (player, data) => {
  if (!data || data.t !== 'guess' || !Number.isInteger(data.n) || data.n < 1 || data.n > 100) return
  if (data.n !== secret) return nv.send(player.username, { t: 'hint', n: data.n, hint: data.n < secret ? 'up' : 'down' })
  nv.db.run('INSERT INTO wins (username, wins) VALUES (?, 1) ON CONFLICT (username) DO UPDATE SET wins = wins + 1', player.username)
  nv.broadcast({ t: 'won', username: player.username, n: secret, top: top() })
  newRound()
})

nv.on('tick', (dtMs) => {
  left -= dtMs
  if (left > 0) return
  nv.broadcast({ t: 'timeout', n: secret })
  newRound()
})

nv.on('leave', (player) => nv.broadcast({ t: 'left', username: player.username }))
nv.on('stop', () => nv.log('kapanıyor; tur', round))

app.js (lobi ve oyun)

showLobby, showGame, showHint, showTop, askPassword, toast gibi arayüz işlevleri senindir.

let joined = null

async function lobby(message) {
  joined = null
  const servers = await nuvaka.servers.list()          // yayıncınınkiler önce
  showLobby(message, servers.map((s) => ({
    id: s.serverId,
    label: `${s.name} · ${s.players}/${s.maxPlayers} · tur ${s.info?.round ?? '-'}`,
    locked: s.needsPassword,
    dim: s.status !== 'running',
  })))
}

async function enter(serverId, password = null) {
  try {
    const { info } = await nuvaka.servers.join(serverId, password)
    joined = serverId
    showGame(info)
  } catch (e) {
    if (e.code === 'bad_password') return askPassword(serverId)          // kendi şifre pencereni aç, enter(id, şifre)
    if (e.code === 'rejected') return lobby(e.data?.reason ?? 'Reddedildi')
    lobby(e.code)                                                         // server_full, invite_only, friends_only …
  }
}

nuvaka.servers.onMessage(({ serverId, data }) => {
  if (serverId !== joined) return
  if (data.t === 'hello') { showGame({ round: data.round }); showTop(data.top) }
  else if (data.t === 'hint') showHint(data.n, data.hint)
  else if (data.t === 'won') { toast(`${data.username} bildi: ${data.n}`); showTop(data.top) }
  else if (data.t === 'timeout') toast(`Süre doldu, sayı ${data.n} idi`)
})
nuvaka.servers.onKicked(({ reason }) => lobby(reason || 'Sunucudan çıkarıldın'))
nuvaka.servers.onClosed(({ reason }) => lobby(reason === 'closed_by_owner' ? 'Sahibi sunucuyu kapattı' : 'Sunucu kapandı'))
nuvaka.servers.onInvite(({ serverId }) => enter(serverId))
nuvaka.servers.onStatus(({ connected, reconnecting }) => setBanner(connected ? '' : reconnecting ? 'Yeniden bağlanıyor…' : 'Bağlantı yok'))

const guess = (n) => nuvaka.servers.send(joined, { t: 'guess', n })
const back = async () => { await nuvaka.servers.leave(joined); lobby() }

// userServers: kendi sunucunu aç, arkadaşını davet et, kapat
async function hostOwn(name) {
  const { serverId } = await nuvaka.servers.create({ name, visibility: 'invite', maxPlayers: 8 })
  await enter(serverId)
}
const inviteFriend = (username) => nuvaka.servers.invite(joined, username)
const closeOwn = () => nuvaka.servers.delete(joined)

nuvaka.ready.then(() => {
  const invited = nuvaka.servers.invited()             // bildirimden açıldıysa doğrudan gir
  return invited ? enter(invited) : lobby()
})

test/server.test.js

import { it, expect } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { createMockNuvaka, installMockNuvaka } from '@nuvaka/extension-sdk/testing'

// nuvaka.db yerine node:sqlite
function memoryDb() {
  const sql = new DatabaseSync(':memory:')
  return {
    exec: (s) => { sql.exec(s); return null },
    run: (s, ...p) => { const r = sql.prepare(s).run(...p); return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) } },
    get: (s, ...p) => sql.prepare(s).get(...p) ?? null,
    all: (s, ...p) => sql.prepare(s).all(...p),
  }
}

// server/main.js klasik betik: yüklenirken globalThis.nuvaka'yı okur → işleyicileri topla, her sahte örneğe bağla
async function loadServer() {
  let srv
  const handlers = {}
  const prev = globalThis.nuvaka
  globalThis.nuvaka = new Proxy({}, { get: (_, k) => (k === 'on' ? (ev, fn) => { handlers[ev] = fn } : srv[k]) })
  await import('../server/main.js')
  globalThis.nuvaka = prev
  return (nv) => { srv = nv; for (const [ev, fn] of Object.entries(handlers)) nv.on(ev, fn) }
}

it('tahmin, kazanç tablosu, ret ve süre', async () => {
  const db = memoryDb()
  const m = installMockNuvaka(createMockNuvaka({
    grants: ['nuvaka.realtime'], realtime: { username: 'ayse' },
    servers: { db, tickHz: 1, script: await loadServer() },
  }))
  const sid = m.serverHost.create({ name: 'Avrupa 1' })
  const got = []
  m.servers.onMessage(({ data }) => { got.push(data) })

  expect((await m.servers.join(sid)).info).toEqual({ round: 1 })
  expect(got[0].t).toBe('hello')                         // join içindeki send katılana ulaştı

  // "yukarı/aşağı" ile ikili arama: en çok 7 tahmin
  let lo = 1, hi = 100, won = null
  while (!won) {
    const n = Math.floor((lo + hi) / 2)
    await m.servers.send(sid, { t: 'guess', n })
    won = got.find((d) => d.t === 'won')
    const hint = got.at(-1)
    if (hint.t === 'hint') hint.hint === 'up' ? (lo = n + 1) : (hi = n - 1)
  }
  expect(won.top).toEqual([{ username: 'ayse', wins: 1 }])
  expect(m.serverHost.server(sid).info).toEqual({ round: 2 })

  db.run('INSERT INTO bans (username) VALUES (?)', 'veli')
  expect(await m.serverHost.join(sid, 'veli')).toBe('Bu sunucudan uzaklaştırıldın')

  for (let i = 0; i < 60; i++) await m.serverHost.tick(sid)  // 60 × 1000 ms
  expect(got.some((d) => d.t === 'timeout')).toBe(true)
})

Nuvaka Apps API v1 · son güncelleme 2026-09-29