Step 10A.8: add accepting the deal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
64fb944e8d
commit
63e52ea4aa
|
|
@ -533,3 +533,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **The Step 6.7 tests were removed one step early.** They bought a ship and leased an Earth bay during the tutorial, which this step forbids, so they could no longer run. CR-001 §6 replaces Step 6.7 with Step 10A.8, which also removes the old automatic rule.
|
- **The Step 6.7 tests were removed one step early.** They bought a ship and leased an Earth bay during the tutorial, which this step forbids, so they could no longer run. CR-001 §6 replaces Step 6.7 with Step 10A.8, which also removes the old automatic rule.
|
||||||
- **Tests for purchases, races and pilot assignment now start from an owner** (`signUpOwner`), since only owners can buy. The "avatar can't be assigned during the tutorial" test puts a ship in the database directly.
|
- **Tests for purchases, races and pilot assignment now start from an owner** (`signUpOwner`), since only owners can buy. The "avatar can't be assigned during the tutorial" test puts a ship in the database directly.
|
||||||
- **`tutorialRunsBeforeDeal = 3` is tested by mocking content** in its own file, like the earlier `cannot-land` test.
|
- **`tutorialRunsBeforeDeal = 3` is tested by mocking content** in its own file, like the earlier `cannot-land` test.
|
||||||
|
|
||||||
|
## Step 10A.8
|
||||||
|
|
||||||
|
- **The deal hands over a content `courier` model at Earth and an Earth bay.** Constants `DEAL_SHIP_MODEL_ID` and `DEAL_BAY_LOCATION_ID` are server code, since they describe the story beat ("his old Courier and his Earth bay"), not tuning.
|
||||||
|
- **The endpoint returns the full new `PlayerState`** so the client can switch straight to the post-tutorial UI.
|
||||||
|
- **Exactly once:** the player lock serializes accepts, and `isDealAvailable` requires `deal_accepted` to be false, so a second or simultaneous accept fails with `deal-not-available` and changes nothing.
|
||||||
|
- **The old automatic tutorial end (`maybeEndTutorial`, Step 6.7) is deleted;** accepting the deal is now the only way the tutorial ends (decision 0.5.4 as replaced).
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { players } from "../db/schema";
|
import type { RealTime } from "@deadhead/sim";
|
||||||
|
import { and, count, eq } from "drizzle-orm";
|
||||||
|
import type { Queryable } from "../db/client";
|
||||||
|
import { bays, companies, dispatches, pilots, players, ships } from "../db/schema";
|
||||||
|
import { gameError } from "../http/errors";
|
||||||
|
import { shipName } from "./names";
|
||||||
|
|
||||||
|
/** The ship model and bay location the deal hands over (decision 0.5.30). */
|
||||||
|
export const DEAL_SHIP_MODEL_ID = "courier";
|
||||||
|
export const DEAL_BAY_LOCATION_ID = "earth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The deal can be accepted now (decision 0.5.30): tutorial on, enough runs
|
* The deal can be accepted now (decision 0.5.30): tutorial on, enough runs
|
||||||
|
|
@ -17,3 +27,56 @@ export function isDealAvailable(
|
||||||
!tutorialRunInFlight
|
!tutorialRunInFlight
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts the deal (Step 10A.8): the loaner goes back to the company; the
|
||||||
|
* player gets the old Courier at Earth crewed by the avatar, the Earth bay, and
|
||||||
|
* the loan; the tutorial ends. Run inside the player lock, so two simultaneous
|
||||||
|
* accepts produce one deal.
|
||||||
|
*/
|
||||||
|
export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime): Promise<void> {
|
||||||
|
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
|
||||||
|
if (!player) throw new Error(`Player ${playerId} not found`);
|
||||||
|
const [running] = await tx
|
||||||
|
.select({ id: dispatches.id })
|
||||||
|
.from(dispatches)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dispatches.playerId, playerId),
|
||||||
|
eq(dispatches.tutorial, true),
|
||||||
|
eq(dispatches.status, "in_flight"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!isDealAvailable(player, running !== undefined)) {
|
||||||
|
throw gameError("deal-not-available", "There is no deal on the table right now.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId));
|
||||||
|
const shipId = randomUUID();
|
||||||
|
await tx.insert(ships).values({
|
||||||
|
id: shipId,
|
||||||
|
playerId,
|
||||||
|
modelId: DEAL_SHIP_MODEL_ID,
|
||||||
|
name: shipName(player.worldSeed, owned!.n),
|
||||||
|
locationId: DEAL_BAY_LOCATION_ID,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
await tx.insert(bays).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
playerId,
|
||||||
|
locationId: DEAL_BAY_LOCATION_ID,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
await tx
|
||||||
|
.update(pilots)
|
||||||
|
.set({ assignedShipId: shipId, locationId: null, status: "idle" })
|
||||||
|
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
|
||||||
|
await tx
|
||||||
|
.update(companies)
|
||||||
|
.set({ loanBalance: content.config.dealLoanAmount })
|
||||||
|
.where(eq(companies.playerId, playerId));
|
||||||
|
await tx
|
||||||
|
.update(players)
|
||||||
|
.set({ tutorialActive: false, loanerLocationId: null, dealAccepted: true })
|
||||||
|
.where(eq(players.id, playerId));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,13 @@ import { resolveDispatch, type RealTime } from "@deadhead/sim";
|
||||||
import { and, asc, eq, lte, sql } from "drizzle-orm";
|
import { and, asc, eq, lte, sql } from "drizzle-orm";
|
||||||
import type { Queryable } from "../db/client";
|
import type { Queryable } from "../db/client";
|
||||||
import { companies, discoveredLocations, dispatches, pilots, players, ships } from "../db/schema";
|
import { companies, discoveredLocations, dispatches, pilots, players, ships } from "../db/schema";
|
||||||
import { maybeEndTutorial } from "./tutorial";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves every in-flight dispatch of a player that has arrived by `now`, in
|
* Resolves every in-flight dispatch of a player that has arrived by `now`, in
|
||||||
* arrival order, applying credits, XP, ship/pilot location and discovery. Run
|
* arrival order, applying credits, XP, ship/pilot location and discovery. Run
|
||||||
* it inside `withPlayerLock`. Each dispatch moves from in_flight to resolved
|
* it inside `withPlayerLock`. Each dispatch moves from in_flight to resolved
|
||||||
* exactly once: the status update is conditional, and effects are applied only
|
* exactly once: the status update is conditional, and effects are applied only
|
||||||
* when it changed a row. Ends the tutorial if its last run just landed
|
* when it changed a row. Returns how many dispatches were resolved.
|
||||||
* (Step 6.7). Returns how many dispatches were resolved.
|
|
||||||
*/
|
*/
|
||||||
export async function resolveArrivedDispatches(
|
export async function resolveArrivedDispatches(
|
||||||
tx: Queryable,
|
tx: Queryable,
|
||||||
|
|
@ -102,6 +100,5 @@ export async function resolveArrivedDispatches(
|
||||||
discoveredIds.add(dispatch.record.destinationId);
|
discoveredIds.add(dispatch.record.destinationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (resolvedCount > 0) await maybeEndTutorial(tx, playerId);
|
|
||||||
return resolvedCount;
|
return resolvedCount;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import type { Queryable } from "../db/client";
|
|
||||||
import { bays, dispatches, pilots, players, ships } from "../db/schema";
|
|
||||||
|
|
||||||
/** Where the avatar ends up when the tutorial ends (decision 0.5.4). */
|
|
||||||
export const TUTORIAL_END_LOCATION_ID = "earth";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ends the tutorial once the player owns a ship, has an Earth bay, and has no
|
|
||||||
* tutorial run in flight (decision 0.5.4): the loaner is removed and the avatar
|
|
||||||
* becomes a normal, unassigned pilot at Earth. Returns whether it ended now.
|
|
||||||
*/
|
|
||||||
export async function maybeEndTutorial(tx: Queryable, playerId: string): Promise<boolean> {
|
|
||||||
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
|
|
||||||
if (!player?.tutorialActive) return false;
|
|
||||||
|
|
||||||
const [ship] = await tx
|
|
||||||
.select({ id: ships.id })
|
|
||||||
.from(ships)
|
|
||||||
.where(eq(ships.playerId, playerId))
|
|
||||||
.limit(1);
|
|
||||||
if (!ship) return false;
|
|
||||||
const [earthBay] = await tx
|
|
||||||
.select({ id: bays.id })
|
|
||||||
.from(bays)
|
|
||||||
.where(and(eq(bays.playerId, playerId), eq(bays.locationId, TUTORIAL_END_LOCATION_ID)));
|
|
||||||
if (!earthBay) return false;
|
|
||||||
const [running] = await tx
|
|
||||||
.select({ id: dispatches.id })
|
|
||||||
.from(dispatches)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(dispatches.playerId, playerId),
|
|
||||||
eq(dispatches.tutorial, true),
|
|
||||||
eq(dispatches.status, "in_flight"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (running) return false;
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.update(players)
|
|
||||||
.set({ tutorialActive: false, loanerLocationId: null })
|
|
||||||
.where(eq(players.id, playerId));
|
|
||||||
await tx
|
|
||||||
.update(pilots)
|
|
||||||
.set({ locationId: TUTORIAL_END_LOCATION_ID, assignedShipId: null, status: "idle" })
|
|
||||||
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { routes } from "@deadhead/schema";
|
||||||
import { realToGame } from "@deadhead/sim";
|
import { realToGame } from "@deadhead/sim";
|
||||||
import { fromNodeHeaders } from "better-auth/node";
|
import { fromNodeHeaders } from "better-auth/node";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { acceptDeal } from "../game/deal";
|
||||||
import { createPlayer } from "../game/new-player";
|
import { createPlayer } from "../game/new-player";
|
||||||
import { loadPlayerState } from "../game/player-state";
|
import { loadPlayerState } from "../game/player-state";
|
||||||
import { acknowledgeSummaries } from "../game/summaries";
|
import { acknowledgeSummaries } from "../game/summaries";
|
||||||
|
|
@ -49,4 +50,11 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
|
||||||
acknowledged: await acknowledgeSummaries(tx, playerId, input.dispatchIds, now),
|
acknowledged: await acknowledgeSummaries(tx, playerId, input.dispatchIds, now),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
registerRoute(app, routes.acceptDeal, options, async (_input, { playerId }) =>
|
||||||
|
withPlayer(ctx, playerId, async (tx, now) => {
|
||||||
|
await acceptDeal(tx, playerId, now);
|
||||||
|
return loadPlayerState(tx, playerId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import type { Queryable } from "../db/client";
|
import type { Queryable } from "../db/client";
|
||||||
import { withPlayerLock } from "../game/lock";
|
import { withPlayerLock } from "../game/lock";
|
||||||
import { resolveArrivedDispatches } from "../game/resolve";
|
import { resolveArrivedDispatches } from "../game/resolve";
|
||||||
import { maybeEndTutorial } from "../game/tutorial";
|
|
||||||
import type { AppContext } from "./context";
|
import type { AppContext } from "./context";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs a game action for a player: takes the player lock, applies any arrivals
|
* Runs a game action for a player: takes the player lock, applies any arrivals
|
||||||
* that are due (so the action sees current state), runs `fn`, then ends the
|
* that are due (so the action sees current state), then runs `fn`.
|
||||||
* tutorial if the action completed its conditions (Step 6.7).
|
|
||||||
*/
|
*/
|
||||||
export function withPlayer<T>(
|
export function withPlayer<T>(
|
||||||
ctx: AppContext,
|
ctx: AppContext,
|
||||||
|
|
@ -17,8 +15,6 @@ export function withPlayer<T>(
|
||||||
const now = ctx.clock();
|
const now = ctx.clock();
|
||||||
return withPlayerLock(ctx.db, playerId, async (tx) => {
|
return withPlayerLock(ctx.db, playerId, async (tx) => {
|
||||||
await resolveArrivedDispatches(tx, playerId, now);
|
await resolveArrivedDispatches(tx, playerId, now);
|
||||||
const result = await fn(tx, now);
|
return fn(tx, now);
|
||||||
await maybeEndTutorial(tx, playerId);
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import { errorResponseSchema, playerStateSchema } from "@deadhead/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { bays, companies, pilots, players, ships } from "../src/db/schema";
|
||||||
|
import { useTestDatabase } from "./db";
|
||||||
|
import { call, getState, signUp, useTestApp, type Guest } from "./game";
|
||||||
|
|
||||||
|
const database = useTestDatabase();
|
||||||
|
const testApp = useTestApp(database);
|
||||||
|
|
||||||
|
/** Fast-forwards a new guest to "four runs done, loaner back at Earth". */
|
||||||
|
async function readyForDeal(guest: Guest) {
|
||||||
|
await database()
|
||||||
|
.db.update(players)
|
||||||
|
.set({
|
||||||
|
tutorialRunsCompleted: content.config.tutorialRunsBeforeDeal,
|
||||||
|
loanerLocationId: "earth",
|
||||||
|
})
|
||||||
|
.where(eq(players.id, guest.playerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const accept = (guest: Guest) => call(testApp().app, guest, "POST", "/api/deal/accept");
|
||||||
|
|
||||||
|
/** Everything the deal touches, read straight from the database. */
|
||||||
|
async function snapshot(guest: Guest) {
|
||||||
|
const { db } = database();
|
||||||
|
return {
|
||||||
|
player: (await db.select().from(players).where(eq(players.id, guest.playerId)))[0],
|
||||||
|
company: (await db.select().from(companies).where(eq(companies.playerId, guest.playerId)))[0],
|
||||||
|
ships: await db.select().from(ships).where(eq(ships.playerId, guest.playerId)),
|
||||||
|
bays: await db.select().from(bays).where(eq(bays.playerId, guest.playerId)),
|
||||||
|
pilots: await db.select().from(pilots).where(eq(pilots.playerId, guest.playerId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("accepting the deal", () => {
|
||||||
|
it("hands over a crewed Courier at Earth, an Earth bay and the loan, and ends the tutorial", async () => {
|
||||||
|
const guest = await signUp(testApp().app);
|
||||||
|
await readyForDeal(guest);
|
||||||
|
expect((await getState(testApp().app, guest)).tutorial?.dealAvailable).toBe(true);
|
||||||
|
|
||||||
|
const response = await accept(guest);
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const state = playerStateSchema.parse(response.json());
|
||||||
|
|
||||||
|
expect(state.tutorial).toBeNull();
|
||||||
|
expect(state.ships).toHaveLength(1);
|
||||||
|
const [ship] = state.ships;
|
||||||
|
expect(ship).toMatchObject({
|
||||||
|
modelId: "courier",
|
||||||
|
status: "idle",
|
||||||
|
locationId: "earth",
|
||||||
|
pilotId: guest.avatarId,
|
||||||
|
});
|
||||||
|
expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]);
|
||||||
|
expect(state.pilots[0]).toMatchObject({
|
||||||
|
isAvatar: true,
|
||||||
|
assignedShipId: ship!.id,
|
||||||
|
locationId: "earth",
|
||||||
|
});
|
||||||
|
expect(state.company.loanBalance).toBe(content.config.dealLoanAmount);
|
||||||
|
|
||||||
|
const after = await snapshot(guest);
|
||||||
|
expect(after.player).toMatchObject({
|
||||||
|
tutorialActive: false,
|
||||||
|
dealAccepted: true,
|
||||||
|
loanerLocationId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with no changes when the deal is not available", async () => {
|
||||||
|
const guest = await signUp(testApp().app);
|
||||||
|
const before = await snapshot(guest);
|
||||||
|
const response = await accept(guest);
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(errorResponseSchema.parse(response.json()).error.code).toBe("deal-not-available");
|
||||||
|
expect(await snapshot(guest)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails while the loaner is at the Moon", async () => {
|
||||||
|
const guest = await signUp(testApp().app);
|
||||||
|
await readyForDeal(guest);
|
||||||
|
await database()
|
||||||
|
.db.update(players)
|
||||||
|
.set({ loanerLocationId: "moon" })
|
||||||
|
.where(eq(players.id, guest.playerId));
|
||||||
|
expect((await accept(guest)).statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails the second time", async () => {
|
||||||
|
const guest = await signUp(testApp().app);
|
||||||
|
await readyForDeal(guest);
|
||||||
|
expect((await accept(guest)).statusCode).toBe(200);
|
||||||
|
const before = await snapshot(guest);
|
||||||
|
const again = await accept(guest);
|
||||||
|
expect(errorResponseSchema.parse(again.json()).error.code).toBe("deal-not-available");
|
||||||
|
expect(await snapshot(guest)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces exactly one deal from two simultaneous accepts", async () => {
|
||||||
|
const guest = await signUp(testApp().app);
|
||||||
|
await readyForDeal(guest);
|
||||||
|
const responses = await Promise.all([accept(guest), accept(guest)]);
|
||||||
|
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
|
||||||
|
const after = await snapshot(guest);
|
||||||
|
expect(after.ships).toHaveLength(1);
|
||||||
|
expect(after.bays).toHaveLength(1);
|
||||||
|
expect(after.company!.loanBalance).toBe(content.config.dealLoanAmount);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -113,7 +113,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
|
||||||
- [x] **Step 10A.5 — Sim: onboarding goal evaluation.** `completedGoals(goals, progress)` / `isGoalConditionMet` (`src/goals.ts`) over a `GoalProgress` summary; tests check each goal just short of and at its condition. Sim line coverage still 100%.
|
- [x] **Step 10A.5 — Sim: onboarding goal evaluation.** `completedGoals(goals, progress)` / `isGoalConditionMet` (`src/goals.ts`) over a `GoalProgress` summary; tests check each goal just short of and at its condition. Sim line coverage still 100%.
|
||||||
- [x] **Step 10A.6 — Schema and database.** Migration `0002_cr001_faster_start`: `companies.loan_balance`, `players.tutorial_runs_completed` / `deal_accepted`, `contract_boards.tutorial_run` (in the unique key), table `claimed_goals`; repayment stored in each dispatch's resolution JSON. API: `company.loanBalance`, `tutorial.runsCompleted` / `dealAvailable`, `goals`, summary `loanRepayment` / `scriptedId`; routes `acceptDeal`, `repayLoan`, `claimGoal`; error codes `tutorial-active`, `deal-not-available`, `unknown-goal`, `goal-not-ready`, `goal-already-claimed`. `architecture.md` updated.
|
- [x] **Step 10A.6 — Schema and database.** Migration `0002_cr001_faster_start`: `companies.loan_balance`, `players.tutorial_runs_completed` / `deal_accepted`, `contract_boards.tutorial_run` (in the unique key), table `claimed_goals`; repayment stored in each dispatch's resolution JSON. API: `company.loanBalance`, `tutorial.runsCompleted` / `dealAvailable`, `goals`, summary `loanRepayment` / `scriptedId`; routes `acceptDeal`, `repayLoan`, `claimGoal`; error codes `tutorial-active`, `deal-not-available`, `unknown-goal`, `goal-not-ready`, `goal-already-claimed`. `architecture.md` updated.
|
||||||
- [x] **Step 10A.7 — Server: tutorial flow v2.** Tutorial boards come from the v2 generator, stored per run and period; each resolved tutorial run increments `tutorial_runs_completed`; `dealAvailable` in state; buying ships, leasing bays and hiring pilots return `409 tutorial-active` during the tutorial. Tests for 6.5/6.6/6.8/6.14 now start from an owner; old Step 6.7 tests removed (their trigger is now impossible; the rule itself goes in 10A.8).
|
- [x] **Step 10A.7 — Server: tutorial flow v2.** Tutorial boards come from the v2 generator, stored per run and period; each resolved tutorial run increments `tutorial_runs_completed`; `dealAvailable` in state; buying ships, leasing bays and hiring pilots return `409 tutorial-active` during the tutorial. Tests for 6.5/6.6/6.8/6.14 now start from an owner; old Step 6.7 tests removed (their trigger is now impossible; the rule itself goes in 10A.8).
|
||||||
- [ ] Step 10A.8 — Server: accept the deal
|
- [x] **Step 10A.8 — Server: accept the deal.** `POST /api/deal/accept` (`acceptDeal` in `src/game/deal.ts`) in one locked transaction: Courier at Earth (named from the seed) crewed by the avatar, Earth bay, loan = `dealLoanAmount`, tutorial off, loaner removed, deal accepted; returns the new player state. `409 deal-not-available` otherwise. Old `maybeEndTutorial` removed (replaces Step 6.7).
|
||||||
- [ ] Step 10A.9 — Server: loan repayment
|
- [ ] Step 10A.9 — Server: loan repayment
|
||||||
- [ ] Step 10A.10 — Server: goals
|
- [ ] Step 10A.10 — Server: goals
|
||||||
- [ ] Step 10A.11 — Client: first launch goes straight to work
|
- [ ] Step 10A.11 — Client: first launch goes straight to work
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue