0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【GCP】GCEで建てたJava版Minecraftサーバー(Spigot)をバージョンアップする(1.18.2→1.19)【Java版Minecraft】

Last updated at Posted at 2022-06-11

概要

GCPに構築したMinecraftサーバー(Spigot)をバージョンアップ(1.18.2→1.19)する。

目的

  • 新要素で遊びたいよね!

事前調査

  • Spigot
    1.19更新来てたので、バージョンアップ敢行。

1. 構築

今回は既に稼働中のMinecraftサーバーのAP部分のみ更新するので、特にコード化は考慮せず、手動で更新とした。

1.1. Spigot 更新

とりあえず、「BuildTools.jar」を更新し、「latest」指定でビルドしてみた。

wget -P /opt/minecraft https://hub.spigotmc.org/jenkins/job/BuildTools/lastStableBuild/artifact/target/BuildTools.jar
java -jar BuildTools.jar --rev latest

…が、出来上がったのは1.18.2(´・ω・`)
ただ、公開されているという情報はあったので、明示的に1.19を指定。

java -jar BuildTools.jar --rev 1.19

無事、「spigot-1.19.jar」がビルド出来た。

1.2. 主要 Plugin 更新

何も考えずに、spigot 差し替えて再起動したところ、Pluginでいろいろエラー発生していたので(まあそうだよな…)、下記も合わせて更新。

Geyser-Spigot更新(旧ファイルをリネームし、最新版をwget)

wget https://ci.opencollab.dev/job/GeyserMC/job/Geyser/job/master/lastSuccessfulBuild/artifact/bootstrap/spigot/target/Geyser-Spigot.jar

Floodgate-spigot更新(旧ファイルをリネームし、最新版をwget)

wget https://ci.opencollab.dev/job/GeyserMC/job/Floodgate/job/master/lastSuccessfulBuild/artifact/spigot/target/floodgate-spigot.jar

あと、chunkを最新のバージョンに合わせて更新しないといけないらしく、初回起動時は下記のコマンドが必要であった。

java -Xms1024M -Xmx8G -jar spigot.jar --nogui --forceUpgrade

ここまでやったところで、とりあえず1.19のクライアント(統合版及びJava版)から接続出来るようになった。

他Pluginでいくつかエラーは吐いているが、これらはまだバージョンアップされていないので待ち。
特にWebブラウザからマップを確認出来る「Dynmap」は結構重宝しているので、早く1.19対応してほしいところ…。

2. その他補足

いくつか、コードやスクリプトを修正しているので、備忘。

2.1. バックアップスクリプト修正

これまでGCEの「metadata_shutdown_script」でMinecraftサーバーの停止やワールドデータのバックアップを行っていたのだが、
よくよく見たらバックアップが取れていなかった…(手動でスクリプト実行した時しか取れていなかった)
「metadata_shutdown_script」でコマンド・スクリプトなどを実行した場合、それらの完了は待ってくれないようで、シャットダウンシーケンス中に終わらない処理は無理ということがわかった。

そのため、バックアップスクリプトの処理タイミングを変更する必要が生じた。
基本的にMinecraftサーバーの停止はDiscordのメッセージを監視してGCEの停止コマンドを実行しているため、この停止コマンド前にバックアップスクリプトを実行することにした。
また、「metadata_shutdown_script」に信頼性がないことがわかったので、いろいろコマンドを仕込むのはやめて(無しにして)、バックアップスクリプトに全て集約させた。

バックアップスクリプトは下記。

service01\scripts\mcs-backup.sh
#!/bin/bash

## 変数定義
WORLDS=(hoge hoge_nether hoge_the_end hoge2 hoge2_nether hoge3 hoge3_nether hoge4)
BACKUP_BUCKET='gs://hoge/'


## ゲーム内にシャットダウン通知
screen -p 0 -S tskserver -X eval 'stuff "say 30秒後にサーバーをシャットダウンします、ログアウトしてください\015"'
sleep 10
screen -p 0 -S tskserver -X eval 'stuff "say 20秒後にサーバーをシャットダウンします、ログアウトしてください\015"'
sleep 10
screen -p 0 -S tskserver -X eval 'stuff "say 10秒後にサーバーをシャットダウンします、ログアウトしてください\015"'
sleep 5
screen -p 0 -S tskserver -X eval 'stuff "say 5秒後にサーバーをシャットダウンします、ログアウトしてください\015"'
sleep 5


## セーブして停止
screen -p 0 -S tskserver -X eval 'stuff 'save-all'\\015'
screen -p 0 -S tskserver -X eval 'stuff 'stop'\\015'


# Backup directory clear
rm -r ${BASH_SOURCE%/*}/backup_worlds/*

for world in ${WORLDS[@]}; do
  cp -r ${BASH_SOURCE%/*}/${world} ${BASH_SOURCE%/*}/backup_worlds/
done

/usr/bin/zip -r ${BASH_SOURCE%/*}/backup_worlds.zip ${BASH_SOURCE%/*}/backup_worlds
/snap/bin/gsutil cp -R ${BASH_SOURCE%/*}/backup_worlds.zip ${BACKUP_BUCKET}
/bin/rm ${BASH_SOURCE%/*}/backup_worlds.zip

2.2. DiscordBotスクリプト修正

上記のバックアップスクリプトを実際に叩く、DiscordBotスクリプトも修正した。

あと、このスクリプト(Bot)がDiscordのメッセージ(コマンド)を監視してMinecraftサーバーの起動停止などを担っているのだが、スマホからの操作を簡易化したかった&子供でも操作出来るようにしたかったので、サーバー操作用のボタンを付けたりしたため、コードがだいぶ長くなっている(今回のバージョンアップ作業とは別のタイミングで更新していた)
こちらもだいぶ苦労したので、余力があれば別途記事を書いてみたいと思う。
button.png

DiscordBotスクリプトは下記。

service01\scripts\mineserver-op.py
# インストールした discord.py を読み込む
from discord.ext import commands
import discord
from discord_buttons_plugin import *
from discord.utils import get
from dislash import InteractionClient, SelectMenu, SelectOption
import os
import time
import subprocess
from subprocess import PIPE
import requests
import aiohttp


# 変数定義
TOKEN = 'hoge' # Discord Bot のトークン
SERVICE_ACCOUNT = 'hoge' # gclud コマンド用サービスアカウント
INSTANCE_NAME = 'hoge' # Minecraftサーバー名(GCP上のVM名)
PROJECT_NAME = 'hoge' # Minecraftサーバーが属している GCP Project
HOST_PROJECT_NAME = 'hoge' # DNS を管理する GCP Project
ZONE_NAME = 'asia-northeast1-b' # Minecraftサーバーが属している GCP ゾーン
DNS_ZONE = 'hoge'
RECORD_NAME = 'hoge'
RECORD_TTL = '300'
RECORD_TYPE = 'A'
MCS_PORT = 'hoge' # Minecraftサーバーポート
CH_ID = hoge # Bot が発言する Discord のチャンネルID
MY_BOT_NAME = 'hoge' # 上記チャンネルの Webhook URL から発言した際の Bot の名前
WEB_HOOK_URL = 'hoge'
MONITOR_CH_IDS = [hoge, hage] # 入退室を監視する対象のボイスチャンネル(チャンネルIDを指定)


Intents = discord.Intents.default()
Intents.members = True

bot = commands.Bot(command_prefix = "!", intents=Intents)
buttons = ButtonsClient(bot)
slash = InteractionClient(bot)

async def notify_callback(id, token):
    url = "https://discord.com/api/v8/interactions/{0}/{1}/callback".format(id, token)
    json = {
        "type": 6
    }
    async with aiohttp.ClientSession() as s:
        async with s.post(url, json=json) as r:
            if 200 <= r.status < 300:
                return

# 起動時に動作する処理
@bot.event
async def on_ready():
    ch = bot.get_channel(CH_ID)
    #await ch.send('Minecraft Administrator Bot 起動')
    #await ch.send('/mcs status')

    # 起動したらターミナルにログイン通知が表示される
    #print('ログインなう')
    #print('/mcs help でコマンドの確認ができるよ')

    # debug
    #command = ['gcloud', f'--account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"']
    #instance_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"'], shell=True)
    #print(str(instance_status))

    await buttons.send(
        content="Minecraft Administrator Bot 起動",
        channel = CH_ID,
        components = [
            ActionRow([
                Button(
                    style = ButtonType().Primary,
                    label = "Start(MCS起動)   ",
                    custom_id = "start",
                ),
                Button(
                    style = ButtonType().Secondary,
                    label = "Stop(MCS停止)    ",
                    custom_id = "stop"
                ),
                Button(
                    style = ButtonType().Success,
                    label = "Status(MCS確認)",
                    custom_id = "status",
                ),
                Button(
                    style = ButtonType().Link,
                    label = "マップ集        ",
                    url="http://mcs.hiko.games/",
                )
            ])
        ]
    )


@buttons.click
async def start(ctx): #関数名は上で指定したカスタムidに対応している
    # fail to interaction 対策
    await notify_callback(ctx.id, ctx.token)

    # debug
    #await ctx.reply("Startボタンが押されました", flags = MessageFlags().EPHEMERAL) #ボタンを押した人のみ見えるメッセージ 
    #await ctx.channel.send("Startボタンが押されました") #誰でも見れる

    #サーバーの起動
    await ctx.channel.send('Minecraftサーバーを起動開始')
    await ctx.channel.send('※「起動完了」が表示されるまで他のコマンドを実行させないこと※')
    await ctx.channel.send('Minecraftサーバーを起動中……')
    subprocess.run([f'gcloud --account={SERVICE_ACCOUNT} compute instances start {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME}'], shell=True)

    instance_status = ""
    while 'RUNNING' in str(instance_status):
        time.sleep(5)
        instance_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"'], shell=True)
        #print(str(instance_status))

    # MinecraftサーバーのGIP取得
    instance_gip = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(networkInterfaces.accessConfigs.natIP)"'], shell=True)
    instance_gip = str(instance_gip)[4:-5]
    #print(instance_gip)

    # Debug
    #instance_gip = '127.0.0.1'
    #print(instance_gip)

    # DNS Record の状態取得
    dns_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} dns record-sets list --zone={DNS_ZONE} --name={RECORD_NAME} --project {HOST_PROJECT_NAME} --format="value(DATA)"'], shell=True)
    dns_status = str(dns_status)[2:-3]
    #print(dns_status)
    # DNS Record 書き換え
    subprocess.run([f'gcloud beta dns record-sets transaction start --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)
    subprocess.run([f'gcloud beta dns record-sets transaction remove {dns_status} --name={RECORD_NAME} --ttl={RECORD_TTL} --type={RECORD_TYPE} --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)
    subprocess.run([f'gcloud beta dns record-sets transaction add {instance_gip} --name={RECORD_NAME} --ttl={RECORD_TTL} --type={RECORD_TYPE} --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)
    subprocess.run([f'gcloud beta dns record-sets transaction execute --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)

    # 確認
    dns_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} dns record-sets list --zone={DNS_ZONE} --name={RECORD_NAME} --project {HOST_PROJECT_NAME} --format="value(DATA)"'], shell=True)
    #print(str(dns_status)[2:-3])

    await ctx.channel.send('……Minecraftサーバーを起動完了')

@buttons.click
async def stop(ctx): #関数名は上で指定したカスタムidに対応している
    # fail to interaction 対策
    await notify_callback(ctx.id, ctx.token)

    # debug
    #await ctx.reply("Stopボタンが押されました", flags = MessageFlags().EPHEMERAL) #ボタンを押した人のみ見えるメッセージ 
    #await ctx.channel.send("Stopボタンが押されました") #誰でも見れる

    #サーバーの停止処理開始
    await ctx.channel.send('Minecraftサーバーを停止開始')
    await ctx.channel.send('※「停止完了」が表示されるまで他のコマンドを実行させないこと※')
    await ctx.channel.send('Minecraftサーバーを停止中……')

    #ワールドデータのバックアップスクリプト実行
    result = subprocess.run([f'ssh -o StrictHostKeyChecking=no -i /opt/discordbot/mcs_key-dev.pem sshadmin@mcs.hiko.games sudo -u root bash /opt/minecraft/mcs_backup.sh'], shell=True)

    if result.returncode == 0:
        await ctx.channel.send('ワールドデータのバックアップが正常終了')
    else:
        await ctx.channel.send('ワールドデータのバックアップが異常終了')

    #サーバー停止実行
    subprocess.run([f'gcloud --account={SERVICE_ACCOUNT} compute instances stop {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME}'], shell=True)

    instance_status = ""
    while 'TERMINATED' in str(instance_status):
        time.sleep(5)
        instance_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"'], shell=True)
        #print(str(instance_status))

    await ctx.channel.send('……Minecraftサーバーを停止完了')

@buttons.click
async def status(ctx): #関数名は上で指定したカスタムidに対応している
    # fail to interaction 対策
    await notify_callback(ctx.id, ctx.token)

    # debug
    #await ctx.reply("Statusボタンが押されました", flags = MessageFlags().EPHEMERAL) #ボタンを押した人のみ見えるメッセージ 
    #await ctx.channel.send("Statusボタンが押されました") #誰でも見れる

    #サーバーの状態確認
    await ctx.channel.send('Minecraftサーバの状態確認中……')
    instance_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"'], shell=True)
    #print(str(instance_status))

    if 'RUNNING' in str(instance_status):
        await ctx.channel.send('……Minecraftサーバ稼働中')
    elif 'TERMINATED' in str(instance_status):
        await ctx.channel.send('……Minecraftサーバ停止中')
    else:
        await ctx.channel.send('……Minecraftサーバの状態不明')


@bot.event
# チャンネルへの入室ステータスが変更されたときの処理
#@bot.event
async def on_voice_state_update(member, before, after):

    ## debug
    #ch = client.get_channel(CH_ID)
    #await ch.send('debug 用メッセージ')
    #
    #main_content = {
    #"content": "debug 用メッセージ"
    #}
    #requests.post(WEB_HOOK_URL,main_content)


    # チャンネルへの入室ステータスが変更されたとき(ミュートON、OFFに反応しないように分岐)
    if before.channel != after.channel:
        ### MinecraftAdminBotではなくHGL Bot に発言させるため、Webhook に投げる ###
        # 退室通知
        if before.channel is not None and before.channel.id in MONITOR_CH_IDS:
            main_content = {
            "content": "**" + before.channel.name + "** から、__" + member.name + "__  が退室なう"
            }
            requests.post(WEB_HOOK_URL,main_content)
        # 入室通知
        if after.channel is not None and after.channel.id in MONITOR_CH_IDS:
            main_content = {
            "content": "**" + after.channel.name + "** に、__" + member.name + "__  が入室なう"
            }
            requests.post(WEB_HOOK_URL,main_content)


@bot.event
async def on_message(message):
    #debug
    #await message.channel.send('[debug]メッセージイベントは拾ってるで')
    #test01 = message.author.bot
    #await message.channel.send(test01)

    #メッセージ送信者がBotだった場合は処理しない、ただし特定のBotのみ処理を継続する
    if message.author.bot == True:
        author = message.author
        if f'{author}' == f'{MY_BOT_NAME}':
            #await message.channel.send('[debug]{MY_BOT_NAME}やで')
            pass
        else:
            #await message.channel.send('[debug]botやで')
            return
    else:
        pass


#サーバーの起動
    if message.content == '/mcs start':
        await message.channel.send('Minecraftサーバーを起動開始')
        await message.channel.send('※「起動完了」が表示されるまで他のコマンドを実行させないこと※')
        await message.channel.send('Minecraftサーバーを起動中……')
        subprocess.run([f'gcloud --account={SERVICE_ACCOUNT} compute instances start {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME}'], shell=True)

        instance_status = ""
        while 'RUNNING' in str(instance_status):
            time.sleep(5)
            instance_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"'], shell=True)
            print(str(instance_status))
        
        # MinecraftサーバーのGIP取得
        instance_gip = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(networkInterfaces.accessConfigs.natIP)"'], shell=True)
        instance_gip = str(instance_gip)[4:-5]
        print(instance_gip)

        # Debug
        #instance_gip = '127.0.0.1'
        #print(instance_gip)

        # DNS Record の状態取得
        dns_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} dns record-sets list --zone={DNS_ZONE} --name={RECORD_NAME} --project {HOST_PROJECT_NAME} --format="value(DATA)"'], shell=True)
        dns_status = str(dns_status)[2:-3]
        print(dns_status)
        # DNS Record 書き換え
        subprocess.run([f'gcloud beta dns record-sets transaction start --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)
        subprocess.run([f'gcloud beta dns record-sets transaction remove {dns_status} --name={RECORD_NAME} --ttl={RECORD_TTL} --type={RECORD_TYPE} --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)
        subprocess.run([f'gcloud beta dns record-sets transaction add {instance_gip} --name={RECORD_NAME} --ttl={RECORD_TTL} --type={RECORD_TYPE} --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)
        subprocess.run([f'gcloud beta dns record-sets transaction execute --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)

        # 確認
        dns_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} dns record-sets list --zone={DNS_ZONE} --name={RECORD_NAME} --project {HOST_PROJECT_NAME} --format="value(DATA)"'], shell=True)
        print(str(dns_status)[2:-3])

        await message.channel.send('……Minecraftサーバーを起動完了')


    #サーバーの停止
    if message.content == '/mcs stop':
        #サーバーの停止処理開始
        await message.channel.send('Minecraftサーバーを停止開始')
        await message.channel.send('※「停止完了」が表示されるまで他のコマンドを実行させないこと※')
        await message.channel.send('Minecraftサーバーを停止中……')

        #ワールドデータのバックアップスクリプト実行
        result = subprocess.run([f'ssh -o StrictHostKeyChecking=no -i /opt/discordbot/mcs_key-dev.pem sshadmin@mcs.hiko.games sudo -u root bash /opt/minecraft/mcs_backup.sh'], shell=True)

        if result.returncode == 0:
            await message.channel.send('ワールドデータのバックアップが正常終了')
        else:
            await message.channel.send('ワールドデータのバックアップが異常終了')

        #サーバー停止実行
        subprocess.run([f'gcloud --account={SERVICE_ACCOUNT} compute instances stop {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME}'], shell=True)

        instance_status = ""
        while 'TERMINATED' in str(instance_status):
           time.sleep(5)
           instance_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"'], shell=True)
           print(str(instance_status))

        await message.channel.send('……Minecraftサーバーを停止完了')


    #ヘルプの表示
    if message.content == '/mcs help':
        await message.channel.send('/mcs start : Minecraftサーバの起動')
        await message.channel.send('/mcs stop : Minecraftサーバの停止')
        await message.channel.send('/mcs status : Minecraftサーバの状態確認')


    #サーバーの状態確認
    if message.content == '/mcs status':
        await message.channel.send('Minecraftサーバの状態確認中……')
        instance_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"'], shell=True)
        print(str(instance_status))

        if 'RUNNING' in str(instance_status):
            await message.channel.send('……Minecraftサーバ稼働中')
        elif 'TERMINATED' in str(instance_status):
            await message.channel.send('……Minecraftサーバ停止中')
        else:
            await message.channel.send('……Minecraftサーバの状態不明')


bot.run(TOKEN)

停止処理部分を抜粋

    #サーバーの停止
    if message.content == '/mcs stop':
        #サーバーの停止処理開始
        await message.channel.send('Minecraftサーバーを停止開始')
        await message.channel.send('※「停止完了」が表示されるまで他のコマンドを実行させないこと※')
        await message.channel.send('Minecraftサーバーを停止中……')

        #ワールドデータのバックアップスクリプト実行
        result = subprocess.run([f'ssh -o StrictHostKeyChecking=no -i /opt/discordbot/mcs_key-dev.pem hoge@hoge sudo -u root bash /opt/minecraft/mcs_backup.sh'], shell=True)

        if result.returncode == 0:
            await message.channel.send('ワールドデータのバックアップが正常終了')
        else:
            await message.channel.send('ワールドデータのバックアップが異常終了')

        #サーバー停止実行
        subprocess.run([f'gcloud --account={SERVICE_ACCOUNT} compute instances stop {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME}'], shell=True)

        instance_status = ""
        while 'TERMINATED' in str(instance_status):
           time.sleep(5)
           instance_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"'], shell=True)
           print(str(instance_status))

        await message.channel.send('……Minecraftサーバーを停止完了')

MinecraftサーバーにSSHで入ってバックアップスクリプト実行、その終了を待ってからGCE停止するように変更した。
今のところ、上手く動いているようで一安心。

3. まとめ

今回のバージョンアップは、「とりあえず動くようにした」だけでまだ全然プレイ出来ていないので、これからいろいろ確認したりする予定。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?