fix reserved turn queue concurrency

This commit is contained in:
2026-07-26 18:32:52 +00:00
parent ab6ed3553a
commit 3cd1ad3b7f
18 changed files with 613 additions and 106 deletions
@@ -48,12 +48,10 @@ test('reserves an argument command in the real game API and reads it back from P
const context = await game.general.me.query();
if (!context) throw new Error('demo1 general is missing');
const generalId = context.general.id;
const original = (
(await game.turns.reserved.getGeneral.query({ generalId })) as unknown as PlainTurn[]
)[29];
const originalNation = (
(await game.turns.reserved.getNation.query({ generalId })) as unknown as PlainTurn[]
)[11];
const originalGeneralSnapshot = await game.turns.reserved.getGeneral.query({ generalId });
const original = (originalGeneralSnapshot.turns as unknown as PlainTurn[])[29];
const originalNationSnapshot = await game.turns.reserved.getNation.query({ generalId });
const originalNation = (originalNationSnapshot.turns as unknown as PlainTurn[])[11];
await page.addInitScript(
({ token }) => {
@@ -71,9 +69,9 @@ test('reserves an argument command in the real game API and reads it back from P
const form = page.getByTestId('command-argument-form');
await expect(form).toBeVisible();
const citySelect = form.locator('select');
const optionValues = await citySelect.locator('option').evaluateAll((options) =>
options.map((option) => (option as HTMLOptionElement).value)
);
const optionValues = await citySelect
.locator('option')
.evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value));
const targetCityId = Number(optionValues.find((value) => Number(value) !== context.general.cityId));
expect(targetCityId).toBeGreaterThan(0);
await citySelect.selectOption(String(targetCityId));
@@ -83,7 +81,7 @@ test('reserves an argument command in the real game API and reads it back from P
await lastTurn.getByRole('button', { name: '배치' }).click();
await expect(lastTurn.locator('.turn-action')).toHaveText('che_화계');
const persisted = (await game.turns.reserved.getGeneral.query({ generalId }))[29];
const persisted = (await game.turns.reserved.getGeneral.query({ generalId })).turns[29];
expect(persisted).toEqual({
index: 29,
action: 'che_화계',
@@ -95,9 +93,9 @@ test('reserves an argument command in the real game API and reads it back from P
await form.getByRole('button', { name: '쌀', exact: true }).click();
await form.locator('input[type=number]').fill('1');
const generalSelect = form.locator('select');
const generalValues = await generalSelect.locator('option').evaluateAll((options) =>
options.map((option) => (option as HTMLOptionElement).value)
);
const generalValues = await generalSelect
.locator('option')
.evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value));
const targetGeneralId = Number(generalValues.find((value) => Number(value) !== generalId));
expect(targetGeneralId).toBeGreaterThan(0);
await generalSelect.selectOption(String(targetGeneralId));
@@ -105,7 +103,7 @@ test('reserves an argument command in the real game API and reads it back from P
const lastNationTurn = nationSection.locator('.reserved-item').nth(11);
await lastNationTurn.getByRole('button', { name: '배치' }).click();
await expect(lastNationTurn.locator('.turn-action')).toHaveText('che_포상');
const persistedNation = (await game.turns.reserved.getNation.query({ generalId }))[11];
const persistedNation = (await game.turns.reserved.getNation.query({ generalId })).turns[11];
expect(persistedNation).toEqual({
index: 11,
action: 'che_포상',
@@ -116,17 +114,21 @@ test('reserves an argument command in the real game API and reads it back from P
fullPage: true,
});
} finally {
const currentGeneral = await game.turns.reserved.getGeneral.query({ generalId });
await game.turns.reserved.setGeneral.mutate({
generalId,
turnIndex: 29,
action: original?.action ?? '휴식',
args: original?.args ?? {},
expectedRevision: currentGeneral.revision,
});
const currentNation = await game.turns.reserved.getNation.query({ generalId });
await game.turns.reserved.setNation.mutate({
generalId,
turnIndex: 11,
action: originalNation?.action ?? '휴식',
args: originalNation?.args ?? {},
expectedRevision: currentNation.revision,
});
}
});
+37 -3
View File
@@ -25,7 +25,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>['turns'][number];
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
@@ -46,6 +46,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const boardAccess = ref<BoardAccess | null>(null);
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
const reservedGeneralRevision = ref(0);
const reservedNationRevision = ref(0);
const globalRecords = ref<RecentRecord[]>([]);
const generalRecords = ref<RecentRecord[]>([]);
const worldHistory = ref<RecentRecord[]>([]);
@@ -260,6 +262,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (!context) {
reservedGeneralTurns.value = null;
reservedNationTurns.value = null;
reservedGeneralRevision.value = 0;
reservedNationRevision.value = 0;
boardAccess.value = null;
resetRecentRecords(null);
loading.value = false;
@@ -322,8 +326,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
messages.value = messageData;
messageContacts.value = contacts;
boardAccess.value = access;
reservedGeneralTurns.value = generalTurns;
reservedNationTurns.value = nationTurns;
reservedGeneralTurns.value = generalTurns.turns;
reservedGeneralRevision.value = generalTurns.revision;
reservedNationTurns.value = nationTurns?.turns ?? null;
reservedNationRevision.value = nationTurns?.revision ?? 0;
if (records) {
globalRecords.value = mergeRecentRecords(globalRecords.value, records.global);
generalRecords.value = mergeRecentRecords(generalRecords.value, records.general);
@@ -485,10 +491,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
turnIndex,
action,
args,
expectedRevision: reservedGeneralRevision.value,
});
reservedGeneralTurns.value = result.turns;
reservedGeneralRevision.value = result.revision;
} catch (err) {
error.value = resolveErrorMessage(err);
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
if (snapshot) {
reservedGeneralTurns.value = snapshot.turns;
reservedGeneralRevision.value = snapshot.revision;
}
}
};
@@ -501,10 +514,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const result = await trpc.turns.reserved.shiftGeneral.mutate({
generalId: id,
amount,
expectedRevision: reservedGeneralRevision.value,
});
reservedGeneralTurns.value = result.turns;
reservedGeneralRevision.value = result.revision;
} catch (err) {
error.value = resolveErrorMessage(err);
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
if (snapshot) {
reservedGeneralTurns.value = snapshot.turns;
reservedGeneralRevision.value = snapshot.revision;
}
}
};
@@ -523,10 +543,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
turnIndex,
action,
args,
expectedRevision: reservedNationRevision.value,
});
reservedNationTurns.value = result.turns;
reservedNationRevision.value = result.revision;
} catch (err) {
error.value = resolveErrorMessage(err);
const snapshot = await trpc.turns.reserved.getNation.query({ generalId: id }).catch(() => null);
if (snapshot) {
reservedNationTurns.value = snapshot.turns;
reservedNationRevision.value = snapshot.revision;
}
}
};
@@ -543,10 +570,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const result = await trpc.turns.reserved.shiftNation.mutate({
generalId: id,
amount,
expectedRevision: reservedNationRevision.value,
});
reservedNationTurns.value = result.turns;
reservedNationRevision.value = result.revision;
} catch (err) {
error.value = resolveErrorMessage(err);
const snapshot = await trpc.turns.reserved.getNation.query({ generalId: id }).catch(() => null);
if (snapshot) {
reservedNationTurns.value = snapshot.turns;
reservedNationRevision.value = snapshot.revision;
}
}
};
+16 -11
View File
@@ -21,6 +21,7 @@ type ChiefEntry = {
name: string | null;
npcState: number | null;
turnTime: string | null;
revision: number;
turns: ChiefTurn[];
};
@@ -59,7 +60,8 @@ type CommandAvailability = {
step?: number;
constValue?: string | number;
options?: Array<{ value: string | number; label: string; color?: string }>;
optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
optionSource?:
'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
tupleLabels?: string[];
}>;
};
@@ -181,9 +183,7 @@ watch(
return;
}
const preferred =
snapshot.me.officerLevel >= 5
? snapshot.me.officerLevel
: snapshot.chiefs[0]?.officerLevel ?? null;
snapshot.me.officerLevel >= 5 ? snapshot.me.officerLevel : (snapshot.chiefs[0]?.officerLevel ?? null);
selectedChiefLevel.value = preferred;
}
);
@@ -315,7 +315,7 @@ const selectedChiefRows = computed(() => {
return buildTurnRows(selectedChief.value);
});
const updateMyTurns = (turns: ChiefEntry['turns']) => {
const updateMyTurns = (turns: ChiefEntry['turns'], revision: number) => {
if (!data.value) {
return;
}
@@ -325,6 +325,7 @@ const updateMyTurns = (turns: ChiefEntry['turns']) => {
return;
}
entry.turns = turns;
entry.revision = revision;
};
const reserveTurn = async (turnIndex: number) => {
@@ -340,9 +341,11 @@ const reserveTurn = async (turnIndex: number) => {
turnIndex,
action: selectedCommand.value.key,
args: commandArgs.value,
expectedRevision: selectedChief.value?.revision ?? 0,
});
updateMyTurns(result.turns);
updateMyTurns(result.turns, result.revision);
} catch (err) {
await loadChiefCenter();
error.value = resolveErrorMessage(err);
}
};
@@ -357,9 +360,11 @@ const clearTurn = async (turnIndex: number) => {
turnIndex,
action: '휴식',
args: {},
expectedRevision: selectedChief.value?.revision ?? 0,
});
updateMyTurns(result.turns);
updateMyTurns(result.turns, result.revision);
} catch (err) {
await loadChiefCenter();
error.value = resolveErrorMessage(err);
}
};
@@ -372,9 +377,11 @@ const shiftTurns = async (amount: number) => {
const result = await trpc.turns.reserved.shiftNation.mutate({
generalId: data.value.me.id,
amount,
expectedRevision: selectedChief.value?.revision ?? 0,
});
updateMyTurns(result.turns);
updateMyTurns(result.turns, result.revision);
} catch (err) {
await loadChiefCenter();
error.value = resolveErrorMessage(err);
}
};
@@ -496,9 +503,7 @@ const shiftTurns = async (amount: number) => {
</div>
<div class="chief-side">
<PanelCard title="사령부 편집" subtitle="선택 명령을 배치하세요">
<div v-if="!isEditingAllowed" class="muted">
사령부 편집은 본인 관직에서만 가능합니다.
</div>
<div v-if="!isEditingAllowed" class="muted">사령부 편집은 본인 관직에서만 가능합니다.</div>
<div v-else>
<CommandSelectForm
:command-table="chiefCommandTable"