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
- İ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; manifesteserverbölümünü (ör.{ "entry": "server/main.js", "tickHz": 5, "maxPlayers": 100 }) venuvaka.realtimeiznini ekler. Şablon SDK^1.3.0ister.--serverarayüzü de kurar; veri eklentisiyle (--kind data) kullanılamaz. - Sunucu kodunu yaz ve yerelde dene
server/main.jsiçindenuvaka.on('start' | 'join' | 'message' | 'tick' | 'leave' | 'stop', …)işleyicilerini yaz (Sunucu kodu). SDK'nın sahte sunucusuyla test et:nuvaka-ext test(Test). - Yayınla
nuvaka-ext publish --changelog "Sunucu"serverolan 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. - 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: falsegelirse sebeperroralanındadır; loglar panelde Loglar düğmesinde ya daGET …/servers/<serverId>/logsucundadır. - 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 …/serversile görülür (status,players,limits); ayarlarPATCH …/servers/<id>ile değişir,POST …/<id>/restartyeniden başlatır,DELETEsiler (Yayıncı API'si). - Uygulama tarafı
nuvaka.servers.list()listeyi getirir;join(serverId, password?)ile katılır,sendile yazar,onMessageile 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" } }]
| Alan | Kural |
|---|---|
entry | zorunlu; 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). |
tickHz | 0–20 tam sayı; 0 = tick yok |
maxPlayers | 1–1000 tam sayı; varsayılan 16. Her sunucunun oyuncu sınırı bunu aşamaz. |
memoryMB | 16–128 tam sayı; varsayılan 64. Her örneğin bellek sınırı. |
userServers | true/false. true ise kullanıcılar da uygulamadan sunucu başlatabilir; yoksa yalnız yayıncının sürekli sunucuları çalışır. |
nuvaka.realtimeizni zorunludur, çünkü oyuncular sunucuya gerçek zamanlı kanaldan bağlanır (yoksa: "server için nuvaka.realtime izni gerekli").- Bilinmeyen alan reddedilir. Veri eklentisinde
serverolamaz. serverolan 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.
| Olay | Ne zaman | Süre sınırı |
|---|---|---|
start() | sunucu açılırken | 2 sn |
join(player) | oyuncu katılırken; false ya da metin (sebep) döndürürse reddeder, başka her dönüş kabul eder | 200 ms |
leave(player) | oyuncu ayrılırken ya da bağlantısı düşünce | 200 ms |
message(player, data) | oyuncudan mesaj gelince | 50 ms |
tick(dtMs) | tickHz sıklığında; dtMs önceki tick'ten beri geçen süre | 50 ms |
stop() | sunucu kapanırken | 1 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 } |
db | uzantı 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.
| Metot | Dö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;undefinedNULLolur. ATTACH,DETACH,load_extension,VACUUM INTOve PRAGMA'lar yasaktır; yalnıztable_info,index_list,foreign_key_listveuser_versionokunabilir.- Kota:
50 MB + kurulum sayısı / 10MB, 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; manifeststorage.quotaMBen ç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çinnuvaka.cloud: writegerekir; 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: trueile 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çar | Yayıncı: uygulamada Profil › Geliştirici › Sunucular ya da API | Kullanıcı, uygulamanın içinden (nuvaka.servers.create); manifestte userServers: true gerekir |
| Ömür | Gözetmen ayakta tutar, çökünce yeniden açar; yeni onaylı sürüm çıkınca yeniden başlatıp ona geçirir | Kimse bağlı değilken 30 dk sonra, en geç 12 saatte kapanır |
| Sınır | 3 + kurulum/100, en çok 20 | Kiş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ür | yalnız çalışırken |
Durum (status)
| Değer | Anlamı |
|---|---|
running | çalışıyor |
stopped | durdu; yayıncı sunucusunu gözetmen yeniden açar |
failed | başlatılamadı ya da çöktü; sebep lastError'da |
disabled | yayıncı durdurdu; gözetmen açmaz |
Başka bir değer gelirse "bekliyor" olarak göster.
Görünürlük
| Değer | Kim katılabilir |
|---|---|
public | uygulamayı kullanan herkes (varsayılan) |
friends | sunucu sahibinin arkadaşları |
password | herkes görür, katılmak için şifre gerekir (4–100 karakter; bcrypt ile saklanır) |
invite | yalnı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}/restart | yeniden başlat |
DELETE /{sid} | sil |
GET /{sid}/logs | son 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ı | Nerede | Dönüş |
|---|---|---|
list() | ikisi | katı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) | ikisi | yalnız kendi başlattığın sunucuyu kapatır; bağlı herkese server.closed (closed_by_owner) |
invite(serverId, username) | ikisi | yalnı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üz | null; sunucu message(player, data) ile alır |
leave(serverId) | arayüz | null; katılmamışsan etkisiz |
invited() | arayüz | davet 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
| Olay | Kısayol | Yük |
|---|---|---|
server.message | onMessage | { serverId, data } — sunucu kodunun send/broadcast'i |
server.kicked | onKicked | { serverId, reason } |
server.closed | onClosed | { 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.invite | onInvite | { serverId } — uygulama açıkken sunucu davetine tıklandı |
realtime.status | onStatus | { 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ı
| Kod | Anlamı |
|---|---|
server_not_found | sunucu yok |
server_not_running | sunucu çalışmıyor |
friends_only | yalnız sahibinin arkadaşları katılabilir |
bad_password | şifre yanlış ya da verilmedi (create'te: şifre 4–100 karakter değil) |
invite_only | yalnız davetliler katılabilir |
server_full | sunucu dolu |
rejected | sunucu kodunun join işleyicisi reddetti; sebep e.data.reason |
server_unavailable | sunucu çalıştırıcısına ulaşılamıyor |
not_joined | sunucuya katılmadan send |
message_too_large | mesaj 16 KB'ı aşıyor |
rate_limited | saniyede 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_owner | yalnız sunucunun sahibi kapatabilir ya da davet edebilir |
not_friend | yalnız arkadaş davet edilebilir |
bad_name, bad_visibility | ad 1–60 karakter değil / görünürlük geçersiz |
not_for_copies, secure_session, ui_only | gizli kopya / güvenli oturum / arka planda join, send, leave |
Sınırlar
| Sınır | |
|---|---|
tickHz | 0–20 |
maxPlayers (sunucu başına oyuncu) | 1–1000 (varsayılan 16) |
memoryMB | 16–128 (varsayılan 64) |
| Mesaj (iki yönde) | ≤ 16 KB JSON |
| Oyuncunun gönderimi | saniyede 20 |
setInfo | ≤ 1 KB |
| Log | son 200 satır |
| İşleyici süreleri | start 2 sn, join/leave 200 ms, message/tick 50 ms, stop 1 sn |
| Yayıncı sunucusu | 3 + 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ı sunucusu | 2 |
| 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
serverolan 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.closedgider, 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 / invite | baş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.serverInvites | sunucuya 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