İçeriğe geç
codebiy
← Yazı masasına dön

Swift bulmaca oyununda hata: doğru şifreyi reddeden kilit

Oyuncunun göremediği bir flag bulmacada ikinci kilide dönüşmüştü, kopyası da kayıt doğrulamasındaydı. Build incelemeye böyle gitti. Hatayı, oyunu oynayan bir insan dakikalar içinde buldu. Düzeltme ve testleri bu yazıda.

Spoiler uyarısı: bu yazıda, anlattığımız oyundaki bir bulmacanın çözümü var.

Üst Kat, iPhone ve iPad için çevrimdışı gerilim oyunumuz: beş oda, taşınabilen beş eşya ve birbirine bağlı alet, anahtar, şifre ve sigorta bulmacaları. Yatak odasında dört haneli kadranı olan bir kutu duruyor. 19 Eylül 2026'da, 1.0.0 sürümü Apple incelemesine gittikten dakikalar sonra stüdyonun sahibi aynı sürümü oynuyordu. Doğru dört haneyi kadrana girdi ama kutu açılmadı.

Bulmacada sorun yoktu. Sorun, bulmacanın yanına iki ayrı yerde eklenmiş bir koşuldaydı. Koşulu, oyunun kodunu bizim yönlendirmemizle yazan yapay zekâ agent'ı koymuştu. Kilit, doğru şifreyi yalnızca ipucu nesnelerinden birini almış, ötekini de incelemiş oyuncudan kabul ediyordu. Kayıt doğrulaması da bu koşula uymayan her kaydı reddediyordu. Build'i geri çektik, koşulu iki yerden de kaldırdık ve ilk gönderimden on bir dakika sonra yeniden gönderdik. Böylece hata App Store'a hiç çıkmadı.

Oyuncunun bulması gerekenler

Üst Kat'ta gece vakti yatak odası: masada küçük bir kutu, duvarda takvim. İkisinin üzerinde de birer artı işareti var. Yatak odası. Kutu masada, lambanın altında duruyor. Takvim de hemen üstündeki duvarda asılı.

Kutu 1903 ile açılıyor. Oyuncuyu bu sayıya iki nesne götürüyor. Yatak odasının duvarındaki takvimde 19 Mart kırmızı kalemle daire içine alınmış, altında da karakterlerden birinin el yazısıyla şu not var:

Önce gün, sonra ay. Dört hane.

İkinci nesne mutfak çekmecesindeki fotoğraf. Arkasında yanıltmaca da düzeltmesi de yazıyor: 1936 binanın yapım yılı, “ama kutuyu açan yıl değil; takvimdeki gün ve ay.” Kutu da oyuncuyu fotoğrafa yönlendiriyor: kadranın yanına küçük bir fotoğraf işareti kazınmış.

Tasarlanan yol belli: fotoğraf, takvim, kadran. Yalnız takvimin tek başına neler verdiğine dikkat etmek gerek: daire içine alınmış bir tarih ve o tarihi dört haneye çevirmenin tarifi. Fotoğrafı çekmecede bırakan oyuncu da 19 Mart'ı 1903 diye okuyabilir. Fotoğrafın işi yalnızca 1936 yazılmasını önlemek.

Kilitteki gizli ön koşul

Hata bildirimi kısaydı: 1903 şifresi reddediliyor. Sebep, kadrana bağlanmış bir koşuldu. Doğru şifre yalnızca fotoğraf çantadaysa ve takvim incelenmişse kabul ediliyordu.

İllüstrasyon: şifreli asma kilidin kadranında, yanındaki kartla aynı dört şekil duruyor ama kırmızı halkası hâlâ kapalı. Kadranda doğru şifre duruyor, kilit yine de açılmıyor.

Böyle bir kuralın nasıl yazıldığını tahmin etmek zor değil. Bunu yazan agent kadar bir insan da yazabilirdi. Tasarlayanın kafasında bulmaca bir yoldur: şunu bul, bunu oku, sonra şifreyi gir. Yolu ön koşula çevirmek, bulmacayı tahmine karşı korumak gibi görünür. Ama odadaki kilit fiziksel bir nesnedir: kadranında hangi rakamların durduğunu bilir, kadranı çevirenin nelere baktığını bilmez.

Bu kuralın kimi geri çevirdiğine bakalım. Bulmacayı yalnızca takvimden çözen oyuncuyu: takvimin kendi metni buna yetiyor. Oyunu ikinci kez oynayan ve şifreyi hatırlayan oyuncuyu. Bir de yardım isteyen oyuncuyu. İpucu düğmesi kademeli çalışıyor ve fotoğrafı almış oyuncuya verdiği son yardım aynen şöyle: “Şifre 1903. Kutuyu açıp sigortayı al.”

Cevabı kendisi veren oyun, takılan oyuncunun ilerleyebileceğine söz vermiş demektir. Üstüne bir de takvime dokunulduğunun kanıtını isteyen kilit, o sözü bozar. Oyuncunun gördüğü ise her durumda aynı: oyun şifre istedi, doğru şifreyi aldı ve hayır dedi. Ekranda bu reddi açıklayan hiçbir şey yok, çünkü koşul bir flag'de duruyor ve oyuncu flag'i göremez.

Aynı koşul kayıt doğrulamasında

Koşulu kadrandan kaldırmak işin yarısıydı. Üst Kat her eylemden sonra oyunu kaydediyor ve kayıt yüklenmeden önce kontrol ediliyor. Kayıt doğrulaması, hikâyede oluşamayacak durumları reddediyor. Örnekte s, çözümlenmiş oyun durumu:

let has = s.flags.contains
// …
if (s.inventory.contains(.fuse) || has("powerOn")) && !has("boxOpened") {
    return false
}
if has("powerOn") && s.inventory.contains(.fuse) { return false }

Kutu açılmadan sigortanın ele geçtiği bir kayıt yüklenmiyor. Sigortanın aynı anda hem çantada hem sigorta panosunda durduğu bir kayıt da yüklenmiyor. Bu katılık bilinçli. Ama doğrulamayı oyun kurallarının ikinci bir kopyası hâline getiriyor. İpucu koşulu oraya da kopyalanmıştı: fotoğraf alınmadan ve takvim incelenmeden açılmış bir kutu imkânsız durum sayılıyordu.

Yalnızca kadranı düzeltseydik, bildirilenden daha kötü bir hata ortaya çıkardı. Oyuncu ipucu nesnelerini atlar, kutuyu açar, sigortayı alır, uygulamayı kapatır. O yolda oluşan kayıt da bir sonraki okumada reddedilir. Bu yüzden koşulu tek değişiklikte iki yerden birden çıkardık.

Koşulun silinmeden önceki hâlini gösteremiyoruz, çünkü bu repo'nun geçmişi daha sonraki bir sürümle başlıyor. Kilit bugünkü hâliyle üç şeye bakıyor: oyuncu nerede, kutu zaten açık mı, ne yazıldı.

// inside perform(actionID:), for an action that starts with "code:"
guard state.room == .bedroom, !state.flags.contains("boxOpened") else {
    // … returns the message that the dial is in the bedroom
}
// …
guard actionID == "code:1903" else {
    // … returns the message that the lock did not open
}
mark("boxOpened")

1.0.0 sürümünün 3 numaralı build'i İstanbul saatiyle 14:25'te Apple incelemesine gitmişti. Hata bildirimi dakikalar sonra geldi. O gönderimi geri çektik ve 4 numaralı build'i saat 14:36'da gönderdik. Koşulun kaldırılması ve aşağıda anlattığımız testler aynı değişiklikteydi. 3 numaralı build mağazaya hiç çıkmadı: 1.0.0 sürümü 4 numaralı build ile satışa çıktı.

Motorun kontrol programı: her checkpoint'te her eylem

Koşulu bu kadar hızlı kaldırıp sonuca güvenebilmemizin sebebi oyunun kuruluş biçimi. Bütün kurallar tek bir değer tipinde, GameEngine'de duruyor. Dosya Swift 6 ile yazılı ve tek import'u Foundation: görünüm yok, ses yok, mağaza kodu yok. Yedi satırlık bir shell script'i o dosyayı bir kontrol programıyla birlikte derleyip Mac'te çalıştırıyor:

swiftc UstKat/GameModel.swift scripts/engine-check.swift \
  -o "$build_dir/engine-check"
"$build_dir/engine-check"

Kontrol programı önce gerçek hikâyeyi ilk odadan sigortaya kadar oynuyor ve her adımdan sonra motorun bir kopyasını saklıyor. Hikâyenin sonraki durumlarını da ekliyor. Ardından birbirinden farklı her checkpoint'te, oyuncunun deneyebileceği her şeyi deniyor. actions ile targets, oyundaki bütün komutların ve nesnelerin listesi. check ise her kontrolü sayıyor ve biri tutmazsa programı durduruyor:

func audit(_ e: GameEngine) {
    check(GameEngine.valid(e.state), "each action preserves save invariants")
    var copy = GameEngine()
    check(copy.restore(data: e.encoded()!), "each action can be saved and resumed")
}
for checkpoint in checkpoints {
    // … a checkpoint already seen is skipped
    for action in actions {
        var e = checkpoint; _ = e.perform(actionID: action); audit(e)
    }
    for room in RoomID.allCases {
        var e = checkpoint; _ = e.travel(to: room); audit(e)
    }
    for target in targets {
        var e = checkpoint; _ = e.interact(targetID: target); audit(e)
        for item in ItemID.allCases {
            var e = checkpoint
            _ = e.interact(targetID: target, selectedItem: item); audit(e)
        }
    }
    var e = checkpoint; _ = e.hint(); audit(e)
}

Her denemeden sonra durum doğrulamadan geçmeli, kodlanıp geri yüklenebilmeli. Motor bir struct ve Swift'te struct'lar değer tipidir. Bu yüzden var e = checkpoint tam bir kopya üretiyor ve her dal kendi kopyasından başlıyor.

Bunların öncesinde program, yeni başlamış bir oyunda sahte hamleler deniyor: sigortayı almak ya da şifreyi koridordan girmek gibi. Hiçbiri bir şey değiştirmemeli. Motora ayrıca bozuk JSON, daha yeni bir şema sürümünden gelen kayıt ve oyuncuyu daha baştan üst kata koyan bir durum veriliyor. Üçü de reddedilmeli.

Bu kontrol programı, hata bildirildiğinde zaten vardı ve o gün 7.432 kontrolü geçmişti. İpucu nesnelerinden biri eksikken de 1903'ü girdi ve bir yanlışlık görmedi, çünkü şifreyi reddeden kilit geride geçerli bir durum bırakıyor. Her şeyi deneyen bu tarama da yalnızca durumun geçerli olup olmadığına bakıyordu. Kutunun açılmasını bekleyen kontrollerin hepsi tasarlanan yoldan geliyordu. UI testleri de öyle.

Aynı ayrımı başka oyunlarımızda da kullanıyoruz: altı bot, pazar oyunumuzun tamamını komut satırından oynuyor, müzik mixer'ımız da ses çalmadan test ediliyor.

Bu hata için eklenen testler

Kontrol programına sonucu doğrulayan bir blok ekledik. ready(), gerçek hikâyeyi sigortaya kadar oynamış bir motor döndürüyor. Blok o durumdan açık kutuyu, sigortayı ve sırayla her ipucu nesnesini çıkarıyor:

// The physical lock accepts the right code even if clue interactions were skipped.
for photoSeen in [false, true] {
    for calendarSeen in [false, true] {
        var lock = ready()
        _ = lock.travel(to: .bedroom)
        lock.state.flags.remove("boxOpened")
        lock.state.inventory.removeAll {
            $0 == .fuse || (!photoSeen && $0 == .photograph)
        }
        if !calendarSeen { lock.state.flags.remove("calendarSeen") }
        _ = lock.perform(actionID: "code:1936")
        check(!lock.state.flags.contains("boxOpened"),
              "wrong code rejected regardless of clues")
        _ = lock.perform(actionID: "code:1903")
        check(lock.state.flags.contains("boxOpened"),
              "1903 opens with any clue combination")
        // … take the fuse, save, restore, switch the power on
    }
}

Blok, fotoğraf ile takvimin dört kombinasyonunu deniyor. Her birinde yanıltıcı yıl reddediliyor, doğru şifre kutuyu açıyor, sigorta alınabiliyor, kayıt geri yükleniyor ve hikâye sürüyor. Kontrol sayısı 7.432'den 7.512'ye çıktı, çünkü dört denemenin her biri önce hikâyeyi yeniden oynuyor ve oynanan her adım da ayrı bir kontrol. Hepsini derleyip çalıştırmak yaklaşık bir saniye sürüyor.

Motorun kontrol programının göremediği yeri bir UI testi kapsıyor: tuş takımını. Test bir iPad simülatöründe çekmeceden anahtarı alıp fotoğrafı orada bırakıyor, takvime dokunmadan geçiyor ve ekrandaki kadranda 1, 9, 0, 3'e basıyor. Sonra sigortayı alıyor, uygulamayı kapatıp yeniden açıyor ve açık kutuyu arıyor. Kuralın kendisini de projenin dersler dosyasına yazdık. Her agent oturumu işe başlamadan önce o dosyayı okuyor.

Bunların hepsi Mac'te ve simülatörde çalışıyor. Kuralların birbirini tuttuğunu kanıtlıyor. Oyunu ilk kez oynayan birinin takvimi bulup bulmayacağı hakkında bir şey söylemiyor, biz de ondan bu sonucu çıkarmıyoruz.

Kilit, kayıt ve test için kurallarımız

  • Kilit şifreye bakar. İpucu nesneleri oyuncu cevabı bulsun diye vardır, cevabın parçası değildir. İpucu düğmesi cevabı veriyorsa kilit de o cevabı kabul etmeli.
  • Testi en kısa yol için yazın: isteğe bağlı her şeyi atlayan yol için. Test, sonucu da doğrulasın. Yalnızca durumun geçerli kalıp kalmadığına bakan bir tarama, haksız yere reddeden kilitte sorun görmez.
  • Katı bir kayıt doğrulaması, kuralların ikinci kopyasıdır. Kuralı ve kopyasını aynı commit'te değiştirin, sonra yeni yolda oluşan bir kaydı yükleyin.
  • Kuralları arayüz olmadan derlenen bir tipte tutun. O zaman “her şeyi her yerde dene” bir döngü olur ve uygulamadan önce çalışır.

Üst Kat iPhone ve iPad için App Store'da. Müşterilerimiz için geliştirdiğimiz mobil uygulamaları da aynı düzenle kuruyoruz: kurallar tek başına test edilebilen bir tipte, arayüz onun üstünde.

OKUMAYA DEVAM ETOyun ekonomisini hisle değil, altı botla ayarladık ↗AVAudioPlayer ile adaptif oyun müziği: üç döngü, ortak saat ↗